The Coline App SDK: apps, tools, file types and surfaces that run inside Coline workspaces, and public websites with Coline Sites. TypeScript, with an in-memory test workspace. MIT.
| 5 | * Account connections: OAuth 2.0 accounts on external services, held by |
| 6 | * Coline and used by the app without the app ever seeing a token. |
| 7 | * |
| 8 | * `scope: "user"` gives every member their own connection (each person's |
| 9 | * GitHub); `scope: "workspace"` is one shared connection an admin makes. |
| 10 | * Coline runs the authorization-code flow (PKCE by default) in a host |
| 11 | * window, stores tokens encrypted, refreshes them, and injects the access |
| 12 | * token into `net.fetch` headers through `{{connection:KEY}}` placeholders |
| 13 | * — only for requests to the connection's declared hosts. |
| 14 | * |
| 15 | * The OAuth client secret is a developer secret (set once in the developer |
| 16 | * console), never an install secret, so store apps work in every |
| 17 | * workspace without each admin registering an OAuth client. |
| 18 | */ |
| 19 |
| 20 | export const connectionKeySchema = z |
| 21 | .string() |
| 22 | .trim() |
| 23 | .regex(/^[a-z][a-z0-9_-]*$/, "Connection keys are lowercase identifiers.") |
| 24 | .max(60); |
| 25 |
| 26 | const httpsUrlSchema = z |
| 27 | .url() |
| 28 | .max(2000) |
| 29 | .refine((value) => value.startsWith("https://"), "OAuth URLs must use https."); |
| 30 |
| 31 | const hostSchema = z |
| 32 | .string() |
| 33 | .trim() |
| 34 | .toLowerCase() |
| 35 | .regex( |
| 36 | /^(\*\.)?([a-z0-9-]+\.)+[a-z]{2,}$/, |
| 37 | "Connection hosts are domains, optionally with a leading `*.` wildcard.", |
| 38 | ) |
| 39 | .max(253); |
| 40 |
| 41 | /** |
| 42 | * Set by Coline's authorization flow; `authorizeParams` cannot replace them |
| 43 | * (a different redirect or scope would bypass what the person consented to). |
| 44 | * `response_type` is ignored rather than rejected: copying `code` is harmless. |
| 45 | */ |
| 46 | const RESERVED_AUTHORIZE_PARAMS = [ |
| 47 | "client_id", |
| 48 | "redirect_uri", |
| 49 | "state", |
| 50 | "scope", |
| 51 | "code_challenge", |
| 52 | "code_challenge_method", |
| 53 | ]; |
| 54 |
| 55 | function hostMatches(host: string, allowed: readonly string[]): boolean { |
| 56 | return allowed.some((entry) => |
| 57 | entry.startsWith("*.") ? host.endsWith(entry.slice(1)) && host.length > entry.length - 1 : host === entry, |
| 58 | ); |
| 59 | } |
| 60 |
| 61 | export const appConnectionSchema = z.strictObject({ |
| 62 | key: connectionKeySchema, |
| 63 | name: z.string().trim().min(1).max(120), |
| 64 | description: z.string().trim().max(500).optional(), |
| 65 | scope: z.enum(["user", "workspace"]).default("user"), |
| 66 | /** Hosts this connection's token may be sent to. Must be in `network.allowedHosts`. */ |
| 67 | hosts: z.array(hostSchema).min(1).max(10), |
| 68 | oauth2: z.strictObject({ |
| 69 | authorizeUrl: httpsUrlSchema, |
| 70 | tokenUrl: httpsUrlSchema, |
| 71 | clientId: z.string().trim().min(1).max(500), |
| 72 | /** Developer secret holding the OAuth client secret. Omit for public PKCE clients. */ |
| 73 | clientSecret: secretKeySchema.optional(), |
| 74 | scopes: z.array(z.string().trim().min(1).max(200)).max(50).default([]), |
| 75 | scopeSeparator: z.string().max(4).default(" "), |
| 76 | pkce: z.boolean().default(true), |
| 77 | /** Extra query parameters for the authorize URL (e.g. `access_type: offline`). */ |
| 78 | authorizeParams: z.record(z.string().max(80), z.string().max(500)).default({}), |
| 79 | /** How the client authenticates at the token endpoint. */ |
| 80 | tokenAuth: z.enum(["body", "basic"]).default("body"), |
| 81 | /** Optional endpoint returning the connected account, for display. */ |
| 82 | accountUrl: httpsUrlSchema.optional(), |
| 83 | /** Dotted path into the account response used as the account label. */ |
| 84 | accountLabelPath: z.string().trim().max(200).optional(), |
| 85 | }), |
| 86 | }).superRefine((connection, ctx) => { |
| 87 | for (const name of Object.keys(connection.oauth2.authorizeParams)) { |
| 88 | if (RESERVED_AUTHORIZE_PARAMS.includes(name.toLowerCase())) { |
| 89 | ctx.addIssue({ |
| 90 | code: "custom", |
| 91 | path: ["oauth2", "authorizeParams", name], |
| 92 | message: `"${name}" is set by Coline's authorization flow; use \`scopes\` for scopes.`, |
| 93 | }); |
| 94 | } |
| 95 | } |
| 96 | const accountUrl = connection.oauth2.accountUrl; |
| 97 | if (accountUrl && !hostMatches(new URL(accountUrl).hostname, connection.hosts)) { |
| 98 | ctx.addIssue({ |
| 99 | code: "custom", |
| 100 | path: ["oauth2", "accountUrl"], |
| 101 | message: "`accountUrl` must be on one of the connection's `hosts`: it is called with the account's token.", |
| 102 | }); |
| 103 | } |
| 104 | }); |
| 105 | export type AppConnection = z.infer<typeof appConnectionSchema>; |
| 106 | export type AppConnectionInput = z.input<typeof appConnectionSchema>; |
| 107 |
| 108 | export const appConnectionsSchema = z |
| 109 | .array(appConnectionSchema) |
| 110 | .max(10) |
| 111 | .default([]) |
| 112 | .refine( |
| 113 | (connections) => |
| 114 | new Set(connections.map((connection) => connection.key)).size === connections.length, |
| 115 | "Connection keys must be unique.", |
| 116 | ); |
| 117 |