Build your own UI
This page shows how a UI of your own calls the IdentityFlow 0.3.1 GraphQL API. It lists the dialogs waiting for the signed-in account, shows and answers one, and starts a workflow. Every query root, mutation and error code is listed in the GraphQL API reference.
Decide first: 0.3.1 accepts only the browser session from its own sign-in
at /graphql, with no token or API key, and is built for a UI on the same
origin as IdentityFlow, behind the same reverse proxy. A backend service or
script without a browser session cannot call the API.
Where your UI must run gives the details.
First step: serve your UI under a free path such as /app/, then
send a request.
Release archives include identity-flow-vX.Y.Z.graphql beside the
IdentityFlow program in your release archive. Use it to generate client types and validate queries. Match
X.Y.Z to your server version. The file describes the served API, including
custom scalars. Production introspection is disabled; the
GraphQL API reference explains the fields and errors.
IdentityFlow 0.3.1 accepts one kind of credential at /graphql: the
identity_flow_session cookie that its OIDC sign-in sets in the browser.
0.3.1 has no token or API key for backend services or scripts that run
without a browser session.
0.3.1 is built for a UI served from the same origin as IdentityFlow, the
origin of auth.oidc.redirectUri in the
configuration. If your UI is a separate web
server, put it behind the same reverse proxy as IdentityFlow, under a path
prefix IdentityFlow does not use, such as /app/. IdentityFlow uses /
(which redirects to /tasks), /tasks, /my, /instances, /workflows,
/access, /analytics, /dead-letters, /auth/, /graphql, /-hooks/,
/_app/, /favicon.ico and /favicon.png.
Everything served from that origin acts with the signed-in account’s
authority. The cookie’s path is /, so your UI’s server receives the session
cookie with every request, and any script on the origin can call the API.
Serve only code you trust there, and do not log request cookies.
Cookie and cross-origin behavior
The cookie is HttpOnly and SameSite=Lax: your code never reads it, and
browsers do not send it with requests from other sites. They do send it with
requests from pages on the same site, such as another subdomain of the same
domain or another port on the same host. At /graphql, IdentityFlow 0.3.1
returns 405 to OPTIONS requests and sends no Access-Control-Allow-*
headers to other origins. SvelteKit rejects cross-origin form, multipart and
text/plain POSTs with 403. CORS controls which browser pages can read
responses; it is not server-side authorization. Serve your UI from the same
origin as IdentityFlow.
To sign someone in, send the browser to /auth/login. Add
?returnTo=/your/path to come back to a page of your UI afterwards; the path
must be on the same origin. A session lasts auth.oidc.sessionTtl, at most 8
hours, and is not extended. After that every call fails with
UNAUTHENTICATED, and your UI sends the browser to /auth/login again. To sign
out, post a same-origin form to /auth/logout.
The account that signed in is the actor for every call. IdentityFlow checks its access on each read and each command; your UI does not enforce access rules itself.
All examples on this page use this helper. It posts the query and its
variables to /graphql and turns an entry in errors[] into an exception with
the error code.
export class GraphQLRequestError extends Error { readonly code: string;
constructor(message: string, code: string) { super(message); this.name = 'GraphQLRequestError'; this.code = code; }}
interface GraphQLResponse<Data> { readonly data?: Data | null; readonly errors?: readonly { readonly message: string; readonly extensions?: { readonly code?: string }; }[];}
export async function graphql<Data>( query: string, variables: Record<string, unknown> = {},): Promise<Data> { const response = await fetch('/graphql', { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ query, variables }), }); // IdentityFlow answers this request with HTTP 200, errors included. // Another status means the request itself was malformed or never reached it. if (!response.ok) { throw new GraphQLRequestError(`HTTP ${response.status}`, 'HTTP_ERROR'); } const body = (await response.json()) as GraphQLResponse<Data>; const [error] = body.errors ?? []; if (error) { throw new GraphQLRequestError(error.message, error.extensions?.code ?? 'UNKNOWN'); } if (!body.data) { throw new GraphQLRequestError('The response has no data', 'UNKNOWN'); } return body.data;}For Go developers: async function … : Promise<Data> returns a value you
await, much like a call that blocks until the result arrives. <Data> is a
type parameter, like a Go generic.
IdentityFlow answers a request like this one with HTTP 200, errors included.
The error sits in errors[], and data is null when a field the response
needs failed, so check errors[] on every response. A body that is not valid
JSON gets 400; the reference lists the other
cases.
assignedDialogs lists the pending dialogs the signed-in account may answer:
those whose assignees include one of the account’s
principals, the { providerId, subject } pairs its
identity provider reports for it.
export const assignedDialogsQuery = /* GraphQL */ ` query AssignedDialogs($after: String) { assignedDialogs { list(first: 100, after: $after) { items { id instanceId workflowName name message dueAt createdAt } pageInfo { hasNextPage endCursor } } } }`;
export interface DialogSummary { readonly id: string; readonly instanceId: string; readonly workflowName: string; readonly name: string; readonly message: string | null; readonly dueAt: string | null; readonly createdAt: string;}
interface DialogPage { readonly items: DialogSummary[]; readonly pageInfo: { readonly hasNextPage: boolean; readonly endCursor: string | null };}
export async function listAssignedDialogs(): Promise<readonly DialogSummary[]> { const dialogs: DialogSummary[] = []; let after: string | null = null; do { const data: { assignedDialogs: { list: DialogPage } } = await graphql(assignedDialogsQuery, { after, }); const page = data.assignedDialogs.list; dialogs.push(...page.items); after = page.pageInfo.hasNextPage ? page.pageInfo.endCursor : null; } while (after); return dialogs;}list(first: 100, after: $after) {- page size, max 100
- previous endCursor
A list returns one page of at most 100 records. listAssignedDialogs asks for
pageInfo with the items and passes endCursor as after for the next page
until hasNextPage is false. Lists have no total count; see
List arguments.
assignedDialogs.find returns one dialog with its taskContext, the data the
dialog carries for the person who answers it.
export const dialogQuery = /* GraphQL */ ` query Dialog($id: ID!) { assignedDialogs { find(id: $id) { id instanceId workflowName name message dueAt taskContext } } }`;
export interface DialogDetail extends Omit<DialogSummary, 'createdAt'> { readonly taskContext: unknown;}
export async function findDialog(id: string): Promise<DialogDetail> { const data = await graphql<{ assignedDialogs: { find: DialogDetail } }>(dialogQuery, { id }); return data.assignedDialogs.find;}Once someone has answered the dialog, it is no longer pending, and find
returns NOT_FOUND. Remove it from your list and reload.
completeDialog answers the dialog with status: COMPLETED and the answer in
data.
export const completeDialogMutation = /* GraphQL */ ` mutation CompleteDialog($input: CompleteDialogInput!) { completeDialog(input: $input) { dialogId instanceId status decidedAt } }`;
export interface DialogCompletion { readonly dialogId: string; readonly instanceId: string; readonly status: 'COMPLETED' | 'ERRORED'; readonly decidedAt: string;}
/** Validate `answer` against the dialog's schema before you call this. */export async function answerDialog(dialogId: string, answer: unknown): Promise<DialogCompletion> { const data = await graphql<{ completeDialog: DialogCompletion }>(completeDialogMutation, { input: { dialogId, status: 'COMPLETED', data: answer }, }); return data.completeDialog;}IdentityFlow checks that the account holds one of the dialog’s assignee principals at the moment it submits, that the dialog is still pending, and that the instance, the run of the workflow, is active or paused. It asks the account’s provider, the identity hook that reports its principals, for the current principals on every command, so a change in your identity system takes effect on the next answer. If the provider does not answer, the command is refused.
Show a refused answer next to its fields. IdentityFlow checks the answer
against the dialog’s schema before it records it, custom checks included. An
answer that does not match is refused with BAD_USER_INPUT, and
extensions.issues lists one { path, message } per rejected field, with the
path relative to the answer. Nothing is recorded and the dialog keeps waiting,
so the person can correct the answer and submit it again. Query
DialogDetail.responseContract for the JSON Schema description of the answer
to build your form and check input before submitting. It is null when no
contract was declared or none could be derived; this does not mean the dialog
accepts any answer. The Engine still validates every completion against the
dialog’s own schema, including custom checks.
status: ERRORED is the other outcome. Pass the error in data: the dialog
step fails with it, and the step’s retry policy decides what happens next.
Both outcomes go through the same input:
input: { dialogId, status: 'COMPLETED', data: answer },- ERRORED fails the step
- checked before recorded
startCatalog lists the Workflow Definitions the account
may start: the newest version of each workflow name and release channel. Pass versions: ALL to
list older versions too; each version has its own id, and a start with that id begins the
instance on that version. Like every instance, it then
moves to compatible versions of its release channel as it
continues. find adds inputContract, a JSON Schema description of the parameters derived from
the definition’s schema; Build a start form from the input schema shows how
to write a schema that makes a good form. inputContract is null when the
definition has no schema or its schema is not a Valibot schema.
export const startCatalogQuery = /* GraphQL */ ` query StartCatalog { startCatalog { list(first: 100) { items { id name version releaseChannel label } } } }`;
export const startableWorkflowQuery = /* GraphQL */ ` query StartableWorkflow($id: ID!) { startCatalog { find(id: $id) { id name version description inputContract } } }`;Pass the id of a catalog item as workflow to startWorkflow:
export const startWorkflowMutation = /* GraphQL */ ` mutation StartWorkflow($input: StartWorkflowInput!) { startWorkflow(input: $input) { id workflowName workflowVersion status createdAt } }`;
export interface StartedInstance { readonly id: string; readonly workflowName: string; readonly workflowVersion: string; readonly status: 'ACTIVE' | 'PAUSED' | 'TERMINATED' | 'COMPLETED' | 'ERRORED'; readonly createdAt: string;}
/** `workflowId` is the `id` of an item from the start catalog. */export async function startWorkflow(workflowId: string, params: unknown): Promise<StartedInstance> { const data = await graphql<{ startWorkflow: StartedInstance }>(startWorkflowMutation, { input: { workflow: workflowId, params }, }); return data.startWorkflow;}input: { workflow: workflowId, params },- catalog item id
- checked before start
IdentityFlow checks params against the definition’s schema before it creates
the instance. Parameters that do not match are refused, and no instance is
created.
Reads return only what the signed-in account may see. The access check runs
inside the database query, before sorting and paging, so a page contains only
records the account may see, and first: 100 returns up to 100 of them. A
view the account has no access to returns an empty list, not an error.
find returns NOT_FOUND with the message Resource not found both for a
record that does not exist and for one the account may not see. Your UI cannot
tell the two apart.
The read views table in the reference lists the access behind each view, and the authorization boundary describes how reads and commands are checked.
An unauthenticated call, for example after the session expired, returns:
{ "errors": [ { "message": "Authentication required", "locations": [{ "line": 1, "column": 3 }], "path": ["assignedDialogs"], "extensions": { "code": "UNAUTHENTICATED" } } ], "data": null}A refused command returns an error code that says what your UI can do next.
This response came from completeDialog sent by an account that is not an
assignee:
{ "errors": [ { "message": "Resource not found", "locations": [{ "line": 1, "column": 42 }], "path": ["completeDialog"], "extensions": { "code": "NOT_FOUND" } } ], "data": null}An account that may not act on a dialog gets the same NOT_FOUND as for a
dialog that does not exist, so the response does not reveal that the dialog
exists. FORBIDDEN comes only from pauseInstance, resumeInstance and
terminateInstance on an instance the account can see. The
errors table in the reference lists every code
and message.
extensions.code | What your UI does |
|---|---|
UNAUTHENTICATED | Send the browser to /auth/login?returnTo=…. |
NOT_FOUND | Treat the record as gone; reload the list. |
FORBIDDEN | Show that the account may not operate this instance. |
BAD_USER_INPUT | Fix the request. An argument is malformed, such as an ID or first above 100, or Input was rejected, such as a dialog answer that fails its schema: show each extensions.issues message next to the field its path names. |
CONFLICT | Reload the record; it changed or is no longer in a state the command accepts. |
LOCKED | Retry after a short wait. |
INTERNAL_SERVER_ERROR | Reload the record and show that the action failed. An operator finds the reason in the log. |
GRAPHQL_VALIDATION_FAILED, GRAPHQL_PARSE_FAILED, or no code | Fix the query. It does not match the schema, is not valid GraphQL, or exceeds a query limit. |
- 0.3.1 has no credential for clients without a browser session.
- 0.3.1 has no subscriptions; reload the list to see new dialogs.
- 0.3.1 has no REST API.