Write handlers
Building a connector, step 7 (HTTP track, 4 of 5). Previous: Read handlers.
Write handlers are what provisioning calls: create and change accounts, add and remove Grants, and
run custom operations. Owlie decides what should change; your handlers only carry it out and report
exactly what happened. Declare provision in the manifest's provides before writing any of them.
Account lifecycle
| Handler | Receives | Returns |
|---|---|---|
<entity>.create | the new record's attributes | the created record |
<entity>.update | identifiers, then the attributes to change | the updated record |
<entity>.delete | identifiers | nothing |
<entity>.enable / <entity>.disable | identifiers | nothing |
Owlie validates the input against the entity schema before your handler runs, so do not reshape or
repair it. Return the system's response record. Treat a 404 on delete as done, since the account is
gone either way; let every other error propagate. Write only the handlers the system supports: an
operation you leave out is simply unavailable, and Owlie never plans it.
import type { Handler, UserRecord } from "@connector/types";
const create: Handler<"user.create"> = async (input, ctx) => {
const response = await ctx.http.post<UserRecord>("/users", { ...ctx.options, json: input });
ctx.validate.resourceSchema("user", response.data, { mode: "warn", operation: "create" });
return response.data;
};
export default create;
A system that deactivates rather than deletes gets disable and enable and no delete. The
account's provisioning profile then says a revoked account ends up disabled.
What a new account gets
The account entity's provisioning_profile declares which operations Owlie may plan and what a new
account receives. Each attribute in the baseline is evaluated per identity:
{
"provisioning_profile": {
"version": 1,
"operations": {
"PROVISION": {
"provision_mode": "direct",
"baseline": {
"attributes": {
"email": { "type": "expression", "value": "identity.email" },
"first_name": { "type": "expression", "value": "identity.first_name" },
"last_name": { "type": "expression", "value": "identity.last_name" }
},
"grants": []
}
},
"REVOKE": { "remove_managed_grants": true, "lifecycle_state_on_revoke": "DISABLED" },
"DISABLE": {},
"ENABLE": {},
"UPDATE_ATTRIBUTES": {},
"ADD_GRANTS": {},
"REMOVE_GRANTS": {},
"SET_GRANTS": {}
}
}
}
- An
expressionis written in OXL, Owlie's expression language, over the identity, such asidentity.email(Expressions lists every identity field). - A
usernameGeneratorbuilds a username from steps and tries the next candidate when a create fails because the value is taken. Yourcreatehandler must report that failure asALREADY_EXISTS(orCONFLICT), usually throughlib/map-error.ts. - Never hard-code customer data. No email domain and no company name in a baseline: build email
and usernames from
identity.email. - Mark attributes the system requires at creation with
"x-provisioning": { "requiredOn": ["PROVISION"] }in the schema. Provisioning refuses to create an account without them, so give each one a baseline. baseline.grantslists Grants every new account receives.provision_modematches the account'screate_semantics:direct,inviteorexternal. A Grants-only connector has nocreatehandler.
Grant handlers
Each writable Grant kind has two handlers, handlers/grants/<kind>/add.ts and remove.ts. They
receive the same input: the Grant kind, the principal (its entity key and identifiers), the target's
identifiers and the qualifier values.
Report "already there" or "already gone" only when the write call's own response proves it, and let every other error propagate. A lookup that fails to find the user or the team fails the write; it is never evidence that the Grant is absent. Owlie absorbs no error on your behalf: a remove recorded as done while the access remains leaves someone with access Owlie reports as gone. Grant writes has the full contract.
import type { Handler } from "@connector/types";
import { applicationCodeOf } from "@owlie/connectors/sdk/v2";
import { resolveTeamId, resolveUserId } from "@connector/lib/lookups.ts";
const remove: Handler<"grant.team_member.remove"> = async (input, ctx) => {
const userId = await resolveUserId(ctx, input.principal.identifiers); // an email that matches nobody throws
const teamId = resolveTeamId(ctx, input.target.identifiers);
try {
await ctx.http.delete(`/teams/${encodeURIComponent(teamId)}/members/${encodeURIComponent(userId)}`, ctx.options);
} catch (error) {
// lib/map-error.ts turns Acme's documented 404 `not_a_member` into NOT_MEMBER. Any other 404
// (an unknown team, a mistyped path) keeps its NOT_FOUND code and fails the remove.
if (applicationCodeOf(error) === "NOT_MEMBER") return ctx.grant.alreadyAbsent();
throw error;
}
};
export default remove;
Test the vendor's code, not the status: a bare 404 also answers a wrong path or an unknown team.
Once mapError returns its own error, isHttpStatus(error, 404) is false, so read the mapped code
with applicationCodeOf.
add mirrors it: catch the error, return ctx.grant.alreadyPresent() when the response says the
Grant already exists (isAlreadyExists(error), once lib/map-error.ts has turned the vendor's code
into ALREADY_MEMBER), and rethrow everything else. For a Grant kind with qualifiers, an existing
membership with a different role is not "already present": read it and change the role.
Before your handler runs, Owlie checks an add's qualifiers against the declaration: required ones
present, none undeclared, each of the right type, enums among their values, and strings matching any
pattern. A remove is checked only for what addressing it needs, because the Grant may have been
observed before a pattern existed. Encode every qualifier you put in a URL path.
Example: HTTP connector with Grant writes has complete add and remove handlers, including the qualifier case.
Custom operations
A custom operation is an action workflows and agents can call that is not an account change or a Grant,
such as "resend invitation" or "sign out all sessions". Declare it in custom_ops:
{
"custom_ops": [
{
"key": "resend-invite",
"title": "Resend invitation",
"params_schema": { "type": "object", "properties": {}, "additionalProperties": false }
}
]
}
and write it as handlers/<account entity>/op/<key>.ts, typed Handler<"user.op.resend-invite">. It receives
the validated params, and ctx.identifiers holds the account it was called on. Custom operations run on the
account entity only: they are actions on a person's account, invoked from the account panel, and a handler on
any other entity can never run. Owlie checks the params
against params_schema before your handler runs.