Skip to main content

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​

HandlerReceivesReturns
<entity>.createthe new record's attributesthe created record
<entity>.updateidentifiers, then the attributes to changethe updated record
<entity>.deleteidentifiersnothing
<entity>.enable / <entity>.disableidentifiersnothing

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 expression is written in OXL, Owlie's expression language, over the identity, such as identity.email (Expressions lists every identity field).
  • A usernameGenerator builds a username from steps and tries the next candidate when a create fails because the value is taken. Your create handler must report that failure as ALREADY_EXISTS (or CONFLICT), usually through lib/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.grants lists Grants every new account receives.
  • provision_mode matches the account's create_semantics: direct, invite or external. A Grants-only connector has no create handler.

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.

Next: Provider errors and quirks