@lanes-sh/link 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +144 -0
- package/bin/lanes +42 -0
- package/instructions/agents/lanes-link-scout.md +73 -0
- package/instructions/skills/lanes-link/SKILL.md +187 -0
- package/package.json +95 -0
- package/src/audit/chain.ts +278 -0
- package/src/audit/conformance.ts +374 -0
- package/src/audit/fanout.ts +97 -0
- package/src/audit/index.ts +218 -0
- package/src/audit/stdout.ts +60 -0
- package/src/auth/index.ts +220 -0
- package/src/auth/oauth/metadata.ts +75 -0
- package/src/auth/oauth/server.ts +385 -0
- package/src/auth/oauth/store.ts +215 -0
- package/src/auth/oidc.ts +206 -0
- package/src/auth/remote.ts +72 -0
- package/src/cli/argv.ts +94 -0
- package/src/cli/callback-page.ts +256 -0
- package/src/cli/commands/connect/accounts.ts +94 -0
- package/src/cli/commands/connect/authorise.ts +298 -0
- package/src/cli/commands/connect/client.ts +284 -0
- package/src/cli/commands/connect/index.ts +398 -0
- package/src/cli/commands/connect/outcome.ts +119 -0
- package/src/cli/commands/connect/requirements.ts +103 -0
- package/src/cli/commands/connect/scopes-gate.ts +146 -0
- package/src/cli/commands/connect/settle.ts +136 -0
- package/src/cli/commands/connect/setup.ts +276 -0
- package/src/cli/commands/mcp/assets.ts +189 -0
- package/src/cli/commands/mcp/harnesses.ts +143 -0
- package/src/cli/commands/mcp/list.ts +70 -0
- package/src/cli/commands/mcp/register.ts +199 -0
- package/src/cli/commands/mcp/stdio.ts +57 -0
- package/src/cli/commands/mcp.ts +22 -0
- package/src/cli/commands/operate/attach.ts +121 -0
- package/src/cli/commands/operate/audit.ts +119 -0
- package/src/cli/commands/operate/inspect.ts +355 -0
- package/src/cli/commands/operate/outputs.ts +206 -0
- package/src/cli/commands/operate/policy.ts +80 -0
- package/src/cli/commands/operate/serve.ts +55 -0
- package/src/cli/commands/operate/status.ts +133 -0
- package/src/cli/commands/operate/token.ts +70 -0
- package/src/cli/commands/operate.ts +27 -0
- package/src/cli/commands/owner/memory.ts +110 -0
- package/src/cli/commands/owner/shared.ts +125 -0
- package/src/cli/commands/owner/skills.ts +92 -0
- package/src/cli/commands/owner/vault.ts +140 -0
- package/src/cli/commands/owner.ts +46 -0
- package/src/cli/commands/profile/removal.ts +278 -0
- package/src/cli/commands/profile/remove.ts +315 -0
- package/src/cli/commands/profile.ts +156 -0
- package/src/cli/commands/secrets.ts +176 -0
- package/src/cli/commands/setup.ts +150 -0
- package/src/cli/commands/target.ts +310 -0
- package/src/cli/config-edit.ts +397 -0
- package/src/cli/endpoint-url.ts +77 -0
- package/src/cli/identity.ts +109 -0
- package/src/cli/lanes.ts +78 -0
- package/src/cli/main.ts +333 -0
- package/src/cli/oauth-error.ts +13 -0
- package/src/cli/oauth-exchange.ts +146 -0
- package/src/cli/oauth.ts +354 -0
- package/src/cli/output.ts +184 -0
- package/src/cli/prompt.ts +180 -0
- package/src/cli/publish.ts +185 -0
- package/src/cli/runtime/discovery.ts +123 -0
- package/src/cli/runtime/open.ts +342 -0
- package/src/cli/runtime/registry.ts +185 -0
- package/src/cli/runtime/select.ts +124 -0
- package/src/cli/runtime.ts +34 -0
- package/src/cli/scopes.ts +63 -0
- package/src/cli/usage.ts +111 -0
- package/src/cli/version.ts +25 -0
- package/src/connectivity/auth/README.md +36 -0
- package/src/connectivity/auth/api-key/index.ts +43 -0
- package/src/connectivity/auth/authorize.ts +49 -0
- package/src/connectivity/auth/basic/index.ts +68 -0
- package/src/connectivity/auth/bearer/index.ts +13 -0
- package/src/connectivity/auth/credential.ts +19 -0
- package/src/connectivity/auth/header/index.ts +18 -0
- package/src/connectivity/auth/index.ts +35 -0
- package/src/connectivity/auth/none/index.ts +12 -0
- package/src/connectivity/auth/oauth-authcode/broker.ts +261 -0
- package/src/connectivity/auth/oauth-authcode/index.ts +64 -0
- package/src/connectivity/auth/oauth-authcode/provider.ts +279 -0
- package/src/connectivity/auth/oauth-authcode/refresh.ts +118 -0
- package/src/connectivity/auth/resolve.ts +61 -0
- package/src/connectivity/auth/strategy/index.ts +14 -0
- package/src/connectivity/capability.ts +164 -0
- package/src/connectivity/connector.ts +175 -0
- package/src/connectivity/context.ts +77 -0
- package/src/connectivity/index.ts +94 -0
- package/src/connectivity/mail/attachments.ts +368 -0
- package/src/connectivity/mail/compose.ts +73 -0
- package/src/connectivity/mail/index.ts +40 -0
- package/src/connectivity/mail/message.ts +82 -0
- package/src/connectivity/mail/nodemailer.d.ts +83 -0
- package/src/connectivity/mail/staging.ts +143 -0
- package/src/connectivity/mail/url.ts +301 -0
- package/src/connectivity/manifest/auth.ts +135 -0
- package/src/connectivity/manifest/bundles.ts +23 -0
- package/src/connectivity/manifest/connector.ts +168 -0
- package/src/connectivity/manifest/credential-ref.ts +73 -0
- package/src/connectivity/manifest/identity.ts +43 -0
- package/src/connectivity/manifest/index.ts +46 -0
- package/src/connectivity/manifest/primitives.ts +20 -0
- package/src/connectivity/manifest/provider.ts +208 -0
- package/src/connectivity/manifest/requirements.ts +146 -0
- package/src/connectivity/manifest/setup.ts +55 -0
- package/src/connectivity/provider.ts +163 -0
- package/src/connectivity/transports/README.md +33 -0
- package/src/connectivity/transports/composite/index.ts +68 -0
- package/src/connectivity/transports/dav/calendar.ts +217 -0
- package/src/connectivity/transports/dav/capabilities.ts +151 -0
- package/src/connectivity/transports/dav/client.ts +226 -0
- package/src/connectivity/transports/dav/contacts.ts +75 -0
- package/src/connectivity/transports/dav/ical.ts +412 -0
- package/src/connectivity/transports/dav/index.ts +143 -0
- package/src/connectivity/transports/dav/operations.ts +43 -0
- package/src/connectivity/transports/dav/request.ts +161 -0
- package/src/connectivity/transports/dav/xml.ts +123 -0
- package/src/connectivity/transports/factory.ts +181 -0
- package/src/connectivity/transports/fs/capabilities.ts +88 -0
- package/src/connectivity/transports/fs/commands.ts +258 -0
- package/src/connectivity/transports/fs/index.ts +121 -0
- package/src/connectivity/transports/fs/operations.ts +45 -0
- package/src/connectivity/transports/fs/paths.ts +120 -0
- package/src/connectivity/transports/fs/result.ts +12 -0
- package/src/connectivity/transports/http/index.ts +255 -0
- package/src/connectivity/transports/imap/attachment.ts +166 -0
- package/src/connectivity/transports/imap/capabilities.ts +158 -0
- package/src/connectivity/transports/imap/client.ts +398 -0
- package/src/connectivity/transports/imap/commands.ts +385 -0
- package/src/connectivity/transports/imap/index.ts +152 -0
- package/src/connectivity/transports/imap/operations.ts +64 -0
- package/src/connectivity/transports/imap/parse.ts +130 -0
- package/src/connectivity/transports/imap/parser.ts +272 -0
- package/src/connectivity/transports/imap/result.ts +15 -0
- package/src/connectivity/transports/imap/send.ts +92 -0
- package/src/connectivity/transports/imap/socket.ts +111 -0
- package/src/connectivity/transports/imap/utf7.ts +136 -0
- package/src/connectivity/transports/index.ts +20 -0
- package/src/connectivity/transports/local/index.ts +173 -0
- package/src/connectivity/transports/mcp/index.ts +215 -0
- package/src/deployments/README.md +63 -0
- package/src/deployments/adapters/audit-blob.ts +203 -0
- package/src/deployments/adapters/filesystem.ts +184 -0
- package/src/deployments/adapters/gcp-secret-manager.ts +492 -0
- package/src/deployments/adapters/gcs.ts +191 -0
- package/src/deployments/adapters/otlp.ts +128 -0
- package/src/deployments/adapters/s3.ts +195 -0
- package/src/deployments/azure/README.md +21 -0
- package/src/deployments/bootstrap.ts +177 -0
- package/src/deployments/deploy.ts +290 -0
- package/src/deployments/driver.ts +157 -0
- package/src/deployments/drivers.ts +35 -0
- package/src/deployments/gcp/Dockerfile +70 -0
- package/src/deployments/gcp/cloudbuild.yaml +31 -0
- package/src/deployments/gcp/driver.ts +175 -0
- package/src/deployments/gcp/gcloud.ts +178 -0
- package/src/deployments/gcp/provision.ts +290 -0
- package/src/deployments/gcp/survey.ts +319 -0
- package/src/deployments/local/README.md +12 -0
- package/src/deployments/prepare.ts +257 -0
- package/src/deployments/steps.ts +137 -0
- package/src/deployments/target.ts +295 -0
- package/src/deployments/upload.ts +207 -0
- package/src/dispatch/context.ts +195 -0
- package/src/dispatch/dispatch.ts +350 -0
- package/src/dispatch/index.ts +32 -0
- package/src/dispatch/staging.ts +102 -0
- package/src/policy/index.ts +179 -0
- package/src/policy/limits.ts +77 -0
- package/src/profile/authorization.ts +81 -0
- package/src/profile/files.ts +71 -0
- package/src/profile/index.ts +76 -0
- package/src/profile/layout.ts +123 -0
- package/src/profile/load.ts +199 -0
- package/src/profile/primitives.ts +45 -0
- package/src/profile/schema.ts +347 -0
- package/src/profile/secret-detection.ts +162 -0
- package/src/profile/targets.ts +152 -0
- package/src/profile/workspace.ts +262 -0
- package/src/providers/custom/index.ts +21 -0
- package/src/providers/custom/load.ts +115 -0
- package/src/providers/custom/template.ts +156 -0
- package/src/providers/example/provider.ts +207 -0
- package/src/providers/google/calendar/index.ts +66 -0
- package/src/providers/google/calendar/redact.ts +40 -0
- package/src/providers/google/contacts/index.ts +50 -0
- package/src/providers/google/contacts/redact.ts +21 -0
- package/src/providers/google/docs/index.ts +45 -0
- package/src/providers/google/drive/hints.ts +28 -0
- package/src/providers/google/drive/index.ts +34 -0
- package/src/providers/google/drive/redact.ts +39 -0
- package/src/providers/google/drive-mcp/index.ts +21 -0
- package/src/providers/google/gmail/api.ts +42 -0
- package/src/providers/google/gmail/attachment.ts +142 -0
- package/src/providers/google/gmail/hints.ts +55 -0
- package/src/providers/google/gmail/index.ts +112 -0
- package/src/providers/google/gmail/redact.ts +56 -0
- package/src/providers/google/gmail/send.ts +365 -0
- package/src/providers/google/gmail-mcp/index.ts +35 -0
- package/src/providers/google/index.ts +10 -0
- package/src/providers/google/shared/oauth.ts +122 -0
- package/src/providers/google/shared/scopes.ts +99 -0
- package/src/providers/google/shared/setup.ts +80 -0
- package/src/providers/google/sheets/hints.ts +45 -0
- package/src/providers/google/sheets/index.ts +70 -0
- package/src/providers/google/sheets/redact.ts +45 -0
- package/src/providers/google/specs/calendar.v3.json +1829 -0
- package/src/providers/google/specs/docs.v1.json +381 -0
- package/src/providers/google/specs/drive.v3.json +2208 -0
- package/src/providers/google/specs/gmail.v1.json +2578 -0
- package/src/providers/google/specs/people.v1.json +506 -0
- package/src/providers/google/specs/sheets.v4.json +1269 -0
- package/src/providers/google/specs/tasks.v1.json +840 -0
- package/src/providers/google/specs/vendor.ts +661 -0
- package/src/providers/google/tasks/index.ts +53 -0
- package/src/providers/google/tasks/redact.ts +34 -0
- package/src/providers/harness.ts +95 -0
- package/src/providers/icloud/calendar/index.ts +27 -0
- package/src/providers/icloud/contacts/index.ts +17 -0
- package/src/providers/icloud/drive/index.ts +47 -0
- package/src/providers/icloud/index.ts +8 -0
- package/src/providers/icloud/mail/index.ts +37 -0
- package/src/providers/icloud/shared/setup.ts +66 -0
- package/src/providers/index.ts +93 -0
- package/src/providers/linear/index.ts +11 -0
- package/src/providers/linear/scopes.ts +7 -0
- package/src/providers/memory/provider.ts +429 -0
- package/src/providers/notion/index.ts +19 -0
- package/src/providers/owner.ts +49 -0
- package/src/providers/scopes.ts +26 -0
- package/src/providers/setup/plan.ts +141 -0
- package/src/providers/setup/provider.ts +323 -0
- package/src/providers/shared/frontmatter.ts +119 -0
- package/src/providers/skills/provider.ts +283 -0
- package/src/providers/skills/store.ts +252 -0
- package/src/providers/vault/provider.ts +194 -0
- package/src/registry/index.ts +36 -0
- package/src/registry/policy-bridge.ts +32 -0
- package/src/registry/reconcile.ts +313 -0
- package/src/registry/registry.ts +240 -0
- package/src/secrets/document.ts +293 -0
- package/src/secrets/index.ts +154 -0
- package/src/secrets/system.ts +173 -0
- package/src/secrets/vault.ts +336 -0
- package/src/server/attachments.ts +197 -0
- package/src/server/container.ts +96 -0
- package/src/server/edge.ts +53 -0
- package/src/server/endpoint.ts +352 -0
- package/src/server/generations.ts +362 -0
- package/src/server/harness.ts +400 -0
- package/src/server/index.ts +331 -0
- package/src/server/logging.ts +41 -0
- package/src/server/mcp/build.ts +68 -0
- package/src/server/mcp/icon.ts +145 -0
- package/src/server/mcp/index.ts +32 -0
- package/src/server/mcp/instructions.ts +245 -0
- package/src/server/mcp/naming.ts +39 -0
- package/src/server/mcp/prompts.ts +78 -0
- package/src/server/mcp/resources.ts +106 -0
- package/src/server/mcp/routing.ts +117 -0
- package/src/server/mcp/schema.ts +78 -0
- package/src/server/mcp/tools.ts +186 -0
- package/src/server/mcp/visibility.ts +132 -0
- package/src/server/oauth.ts +222 -0
- package/src/server/rebinding.ts +53 -0
- package/src/server/stdio.ts +192 -0
- package/src/stores/blobs/conformance.ts +223 -0
- package/src/stores/blobs/index.ts +125 -0
- package/src/stores/blobs/testing.ts +49 -0
- package/src/stores/state/index.ts +247 -0
- package/src/stores/state/keys.ts +68 -0
- package/src/stores/state/testing.ts +41 -0
package/src/auth/oidc.ts
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { hashToken } from './oauth/store.ts';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Verifying a token an issuer you already run handed out.
|
|
5
|
+
*
|
|
6
|
+
* The alternative to this endpoint issuing its own. Named for the protocol and
|
|
7
|
+
* not for any vendor, on the same reasoning that makes the database adapter
|
|
8
|
+
* `s3` and not `supabase` (ADR-013): the issuer is a URL, so pointing this
|
|
9
|
+
* at a different provider is an edit to a config file rather than a branch in
|
|
10
|
+
* the code.
|
|
11
|
+
*
|
|
12
|
+
* Three checks, and dropping any of them makes the other two decorative:
|
|
13
|
+
*
|
|
14
|
+
* - **Audience.** The token must name this application. Without it, a token the
|
|
15
|
+
* same issuer minted for any *other* application it serves would open this
|
|
16
|
+
* endpoint — the confused-deputy case the MCP authorization spec calls out.
|
|
17
|
+
* Every large issuer serves many applications, so this is the check that
|
|
18
|
+
* matters most and the one most easily left out, because everything works
|
|
19
|
+
* without it.
|
|
20
|
+
* - **Expiry**, so a token that has been revoked by lapsing actually stops.
|
|
21
|
+
* - **Subject**, against an allowlist. The issuer will happily vouch for every
|
|
22
|
+
* account it has; which of them is *you* is not something it knows.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export type FetchLike = (input: string | URL, init?: RequestInit) => Promise<Response>;
|
|
26
|
+
|
|
27
|
+
export interface OidcVerifierOptions {
|
|
28
|
+
readonly issuer: string;
|
|
29
|
+
/** The audience a token must name — this application's client id at the issuer. */
|
|
30
|
+
readonly audience: string;
|
|
31
|
+
readonly allowedSubjects: readonly string[];
|
|
32
|
+
/**
|
|
33
|
+
* Where to ask about a token, when the issuer's metadata does not say.
|
|
34
|
+
*
|
|
35
|
+
* Access tokens are frequently opaque, so validating one means asking the
|
|
36
|
+
* issuer. RFC 7662 standardised that as `introspection_endpoint` in the
|
|
37
|
+
* discovery document — and several large issuers ship an equivalent without
|
|
38
|
+
* advertising one, so this is the escape hatch for those. A URL in config, not
|
|
39
|
+
* a case in a switch.
|
|
40
|
+
*/
|
|
41
|
+
readonly introspectionEndpoint?: string | undefined;
|
|
42
|
+
/**
|
|
43
|
+
* Narrower than `typeof fetch` on purpose.
|
|
44
|
+
*
|
|
45
|
+
* The runtime's own type carries extensions — `preconnect` among them — that
|
|
46
|
+
* nothing here calls, and requiring them would mean every test stub had to
|
|
47
|
+
* implement them to be assignable.
|
|
48
|
+
*/
|
|
49
|
+
readonly fetch?: FetchLike;
|
|
50
|
+
readonly now?: () => number;
|
|
51
|
+
/** How long a positive answer is reused. Zero disables the cache. */
|
|
52
|
+
readonly cacheTtlMs?: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface VerifiedSubject {
|
|
56
|
+
readonly subject: string;
|
|
57
|
+
readonly expiresAt: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface Introspection {
|
|
61
|
+
readonly active: boolean;
|
|
62
|
+
readonly audiences: readonly string[];
|
|
63
|
+
readonly subjects: readonly string[];
|
|
64
|
+
readonly expiresAt: number | null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A minute. Long enough to spare a round trip per call, short enough to notice. */
|
|
68
|
+
const DEFAULT_CACHE_TTL_MS = 60_000;
|
|
69
|
+
|
|
70
|
+
export class OidcVerifier {
|
|
71
|
+
readonly #options: OidcVerifierOptions;
|
|
72
|
+
readonly #fetch: FetchLike;
|
|
73
|
+
readonly #now: () => number;
|
|
74
|
+
readonly #cache = new Map<string, VerifiedSubject>();
|
|
75
|
+
#discovered: Promise<Record<string, unknown>> | null = null;
|
|
76
|
+
|
|
77
|
+
constructor(options: OidcVerifierOptions) {
|
|
78
|
+
this.#options = options;
|
|
79
|
+
this.#fetch = options.fetch ?? fetch;
|
|
80
|
+
this.#now = options.now ?? Date.now;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The subject this token belongs to, or null if it does not open this endpoint. */
|
|
84
|
+
async verify(token: string): Promise<VerifiedSubject | null> {
|
|
85
|
+
const key = hashToken(token);
|
|
86
|
+
const cached = this.#cache.get(key);
|
|
87
|
+
if (cached && cached.expiresAt > this.#now()) return cached;
|
|
88
|
+
this.#cache.delete(key);
|
|
89
|
+
|
|
90
|
+
const introspection = await this.#introspect(token);
|
|
91
|
+
if (!introspection?.active) return null;
|
|
92
|
+
|
|
93
|
+
if (!introspection.audiences.includes(this.#options.audience)) return null;
|
|
94
|
+
if (introspection.expiresAt !== null && introspection.expiresAt <= this.#now()) return null;
|
|
95
|
+
|
|
96
|
+
const subject = introspection.subjects.find((candidate) =>
|
|
97
|
+
this.#options.allowedSubjects.includes(candidate),
|
|
98
|
+
);
|
|
99
|
+
if (!subject) return null;
|
|
100
|
+
|
|
101
|
+
const ttl = this.#options.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
|
|
102
|
+
const verified: VerifiedSubject = {
|
|
103
|
+
subject,
|
|
104
|
+
// Never cached past the token's own expiry, however generous the TTL.
|
|
105
|
+
expiresAt: Math.min(this.#now() + ttl, introspection.expiresAt ?? Infinity),
|
|
106
|
+
};
|
|
107
|
+
if (ttl > 0) this.#cache.set(key, verified);
|
|
108
|
+
return verified;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async #endpoint(): Promise<string | null> {
|
|
112
|
+
if (this.#options.introspectionEndpoint) return this.#options.introspectionEndpoint;
|
|
113
|
+
|
|
114
|
+
this.#discovered ??= this.#fetch(
|
|
115
|
+
`${this.#options.issuer.replace(/\/$/, '')}/.well-known/openid-configuration`,
|
|
116
|
+
{ signal: AbortSignal.timeout(5_000) },
|
|
117
|
+
)
|
|
118
|
+
.then((response) => (response.ok ? response.json() : {}))
|
|
119
|
+
.catch(() => ({}));
|
|
120
|
+
|
|
121
|
+
const metadata = await this.#discovered;
|
|
122
|
+
const endpoint = metadata['introspection_endpoint'];
|
|
123
|
+
return typeof endpoint === 'string' ? endpoint : null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Two request shapes, because two are in the wild.
|
|
128
|
+
*
|
|
129
|
+
* RFC 7662 is a form POST of `token`. The other common spelling is a GET with
|
|
130
|
+
* the token in the query string, which several issuers ship instead. Trying
|
|
131
|
+
* the standard one first and the other on failure covers both without either
|
|
132
|
+
* being named in a conditional.
|
|
133
|
+
*/
|
|
134
|
+
async #introspect(token: string): Promise<Introspection | null> {
|
|
135
|
+
const endpoint = await this.#endpoint();
|
|
136
|
+
if (!endpoint) {
|
|
137
|
+
// Fail closed and say why. Falling back to a check that cannot see the
|
|
138
|
+
// audience would leave the confused-deputy hole open while looking like
|
|
139
|
+
// it verified something.
|
|
140
|
+
throw new Error(
|
|
141
|
+
`The issuer ${this.#options.issuer} publishes no introspection_endpoint. ` +
|
|
142
|
+
'Set auth.authorization.introspection_endpoint to the URL that answers ' +
|
|
143
|
+
'questions about a token, or this endpoint cannot check who a token was issued to.',
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const posted = await this.#ask(endpoint, {
|
|
148
|
+
method: 'POST',
|
|
149
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
150
|
+
body: new URLSearchParams({ token }).toString(),
|
|
151
|
+
});
|
|
152
|
+
if (posted) return posted;
|
|
153
|
+
|
|
154
|
+
const url = new URL(endpoint);
|
|
155
|
+
url.searchParams.set('access_token', token);
|
|
156
|
+
return this.#ask(url.toString(), { method: 'GET' });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async #ask(url: string, init: RequestInit): Promise<Introspection | null> {
|
|
160
|
+
try {
|
|
161
|
+
const response = await this.#fetch(url, { ...init, signal: AbortSignal.timeout(5_000) });
|
|
162
|
+
if (!response.ok) return null;
|
|
163
|
+
return normalise((await response.json()) as Record<string, unknown>, this.#now());
|
|
164
|
+
} catch {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* One shape out of several spellings of the same three facts.
|
|
172
|
+
*
|
|
173
|
+
* `aud` may be a string or an array, and some issuers put the client id in
|
|
174
|
+
* `azp` instead. Expiry arrives as an absolute `exp` in seconds or as a relative
|
|
175
|
+
* `expires_in`. Identity is `sub`, and an issuer that also asserts a *verified*
|
|
176
|
+
* email is asserting something an operator would rather write in an allowlist
|
|
177
|
+
* than a numeric id — so both are offered, and an unverified one never is.
|
|
178
|
+
*/
|
|
179
|
+
function normalise(body: Record<string, unknown>, now: number): Introspection {
|
|
180
|
+
const strings = (value: unknown): string[] =>
|
|
181
|
+
typeof value === 'string' ? [value] : Array.isArray(value) ? value.filter((v) => typeof v === 'string') : [];
|
|
182
|
+
|
|
183
|
+
const expiresAt =
|
|
184
|
+
typeof body['exp'] === 'number'
|
|
185
|
+
? body['exp'] * 1000
|
|
186
|
+
: typeof body['expires_in'] === 'number'
|
|
187
|
+
? now + body['expires_in'] * 1000
|
|
188
|
+
: typeof body['expires_in'] === 'string' && /^\d+$/.test(body['expires_in'])
|
|
189
|
+
? now + Number(body['expires_in']) * 1000
|
|
190
|
+
: null;
|
|
191
|
+
|
|
192
|
+
const emailVerified = body['email_verified'];
|
|
193
|
+
const verified = emailVerified === true || emailVerified === 'true';
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
// RFC 7662 says `active`. A 200 from an endpoint that does not use the field
|
|
197
|
+
// is itself the answer, so its absence is not treated as inactive.
|
|
198
|
+
active: body['active'] !== false,
|
|
199
|
+
audiences: [...strings(body['aud']), ...strings(body['azp'])],
|
|
200
|
+
subjects: [
|
|
201
|
+
...strings(body['sub']),
|
|
202
|
+
...(verified ? strings(body['email']) : []),
|
|
203
|
+
],
|
|
204
|
+
expiresAt,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { ownerPrincipal, parseBearer, type AuthOutcome, type Authenticator } from './index.ts';
|
|
2
|
+
import type { OAuthStore } from './oauth/store.ts';
|
|
3
|
+
import type { OidcVerifier } from './oidc.ts';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The two ways a remote client's token becomes a principal.
|
|
7
|
+
*
|
|
8
|
+
* Both resolve to the **owner** principal, and that is a deliberate limit rather
|
|
9
|
+
* than an omission. There is one person behind this endpoint; what a caller may
|
|
10
|
+
* do is decided by the profile's policy per capability per call, not by which
|
|
11
|
+
* credential opened the door. Delegated principals are additive later — the
|
|
12
|
+
* dispatch path already takes a principal rather than assuming the owner — and
|
|
13
|
+
* inventing a second kind now would mean inventing the policy axis to go with
|
|
14
|
+
* it before anything needed one.
|
|
15
|
+
*
|
|
16
|
+
* Neither of these ever reports `missing` for a credential it simply does not
|
|
17
|
+
* recognise. That is what the chain's ranking is for: a token this link cannot
|
|
18
|
+
* place is `invalid` from its point of view and `missing` only if no link saw
|
|
19
|
+
* anything at all.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** A token this endpoint issued through its own authorization flow. */
|
|
23
|
+
export class IssuedTokenAuthenticator implements Authenticator {
|
|
24
|
+
readonly #store: OAuthStore;
|
|
25
|
+
readonly #profile: string;
|
|
26
|
+
|
|
27
|
+
constructor(store: OAuthStore, profile: string) {
|
|
28
|
+
this.#store = store;
|
|
29
|
+
this.#profile = profile;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async authenticate(header: string | null | undefined): Promise<AuthOutcome> {
|
|
33
|
+
const presented = parseBearer(header);
|
|
34
|
+
if (presented === null) return { ok: false, reason: header ? 'malformed' : 'missing' };
|
|
35
|
+
|
|
36
|
+
const record = await this.#store.token(presented);
|
|
37
|
+
// `kind` matters: a refresh token is a credential for the token endpoint and
|
|
38
|
+
// must not open the resource. They are indistinguishable as strings, so the
|
|
39
|
+
// check is the only thing separating them.
|
|
40
|
+
if (!record || record.kind !== 'access') return { ok: false, reason: 'invalid' };
|
|
41
|
+
|
|
42
|
+
return { ok: true, principal: ownerPrincipal(this.#profile) };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A token an external issuer minted, verified against that issuer. */
|
|
47
|
+
export class OidcAuthenticator implements Authenticator {
|
|
48
|
+
readonly #verifier: OidcVerifier;
|
|
49
|
+
readonly #profile: string;
|
|
50
|
+
|
|
51
|
+
constructor(verifier: OidcVerifier, profile: string) {
|
|
52
|
+
this.#verifier = verifier;
|
|
53
|
+
this.#profile = profile;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async authenticate(header: string | null | undefined): Promise<AuthOutcome> {
|
|
57
|
+
const presented = parseBearer(header);
|
|
58
|
+
if (presented === null) return { ok: false, reason: header ? 'malformed' : 'missing' };
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const verified = await this.#verifier.verify(presented);
|
|
62
|
+
return verified
|
|
63
|
+
? { ok: true, principal: ownerPrincipal(this.#profile) }
|
|
64
|
+
: { ok: false, reason: 'invalid' };
|
|
65
|
+
} catch {
|
|
66
|
+
// The issuer being unreachable, or misconfigured, is not an authorisation.
|
|
67
|
+
// Failing closed here means an outage at the identity provider closes the
|
|
68
|
+
// endpoint rather than opening it.
|
|
69
|
+
return { ok: false, reason: 'invalid' };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
package/src/cli/argv.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { GlobalFlags } from './runtime.ts';
|
|
2
|
+
import type { OwnerFlags } from './commands/owner.ts';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Turning argv into a command path and a flag bag.
|
|
6
|
+
*
|
|
7
|
+
* Hand-rolled rather than delegated to a framework: the grammar is small, and a
|
|
8
|
+
* dependency that parses argv in a process that also holds credential-store
|
|
9
|
+
* keys is not worth the convenience.
|
|
10
|
+
*
|
|
11
|
+
* Separate from `main.ts` so it can be tested. While the parser lived inside
|
|
12
|
+
* the bin entry it was unreachable — importing the module ran the CLI.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type Flags = Record<string, string | boolean>;
|
|
16
|
+
|
|
17
|
+
export interface Parsed {
|
|
18
|
+
readonly command: string[];
|
|
19
|
+
readonly flags: Flags;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function parseArgv(argv: readonly string[]): Parsed {
|
|
23
|
+
const command: string[] = [];
|
|
24
|
+
const flags: Flags = {};
|
|
25
|
+
|
|
26
|
+
for (let index = 0; index < argv.length; index++) {
|
|
27
|
+
const argument = argv[index]!;
|
|
28
|
+
|
|
29
|
+
if (!argument.startsWith('--')) {
|
|
30
|
+
command.push(argument);
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const body = argument.slice(2);
|
|
35
|
+
const equals = body.indexOf('=');
|
|
36
|
+
|
|
37
|
+
if (equals !== -1) {
|
|
38
|
+
flags[body.slice(0, equals)] = body.slice(equals + 1);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const next = argv[index + 1];
|
|
43
|
+
if (next !== undefined && !next.startsWith('--')) {
|
|
44
|
+
flags[body] = next;
|
|
45
|
+
index++;
|
|
46
|
+
} else {
|
|
47
|
+
flags[body] = true;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return { command, flags };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A flag's value, when it was given one.
|
|
56
|
+
*
|
|
57
|
+
* `--profile` with nothing after it parses to `true`, which is not a profile
|
|
58
|
+
* name — so every string-valued flag has to check, and this is that check
|
|
59
|
+
* written once.
|
|
60
|
+
*/
|
|
61
|
+
export function text(flags: Flags, name: string): string | undefined {
|
|
62
|
+
const value = flags[name];
|
|
63
|
+
return typeof value === 'string' ? value : undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The flags every command accepts. */
|
|
67
|
+
export function globalFlags(flags: Flags): GlobalFlags {
|
|
68
|
+
return {
|
|
69
|
+
profile: text(flags, 'profile'),
|
|
70
|
+
target: text(flags, 'target'),
|
|
71
|
+
quiet: flags['quiet'] === true,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The owner layer's flags — the global set plus what `memory`, `skills` and
|
|
77
|
+
* `vault` add.
|
|
78
|
+
*
|
|
79
|
+
* The kebab-case spellings live here and nowhere else, so the one place that
|
|
80
|
+
* knows `--display-name` is the one place that parses argv.
|
|
81
|
+
*/
|
|
82
|
+
export function ownerFlags(flags: Flags): OwnerFlags {
|
|
83
|
+
return {
|
|
84
|
+
...globalFlags(flags),
|
|
85
|
+
show: flags['show'] === true,
|
|
86
|
+
raw: flags['raw'] === true,
|
|
87
|
+
connection: text(flags, 'connection'),
|
|
88
|
+
title: text(flags, 'title'),
|
|
89
|
+
tag: text(flags, 'tag'),
|
|
90
|
+
description: text(flags, 'description'),
|
|
91
|
+
file: text(flags, 'file'),
|
|
92
|
+
yes: flags['yes'] === true,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The page the browser lands on when a connect flow ends.
|
|
3
|
+
*
|
|
4
|
+
* It is the only HTML this repository serves to a human, and for a few seconds
|
|
5
|
+
* it is the whole product — so it is the product's own page rather than a
|
|
6
|
+
* bespoke one. The design is lifted from the pages the Lanes API serves after
|
|
7
|
+
* someone authorises something (the workspace invite and the email
|
|
8
|
+
* verification): an outline-only card, a Lora heading, native text softened
|
|
9
|
+
* with opacity, and the shared one-row footer.
|
|
10
|
+
*
|
|
11
|
+
* Two decisions are worth keeping:
|
|
12
|
+
*
|
|
13
|
+
* - **Nothing is painted.** `color-scheme: light dark` plus a transparent
|
|
14
|
+
* background means the browser supplies its own canvas, so the page is
|
|
15
|
+
* correct in either mode without a theme switch, a media query per colour, or
|
|
16
|
+
* a preference to read. It replaced a hardcoded `#0d1117`, which was a black
|
|
17
|
+
* rectangle on a light machine.
|
|
18
|
+
* - **Colour is reserved for status.** Not for emphasis, and not for the
|
|
19
|
+
* heading — a green *word* said nothing the word "Connected" did not, and
|
|
20
|
+
* that is why it went. A check is different: it is the glyph the Lanes app
|
|
21
|
+
* already puts against a connected integration, so a reader arriving from the
|
|
22
|
+
* app meets the mark they left it with. Both outcomes carry one now, the
|
|
23
|
+
* check on success and the red heading on failure, and nothing else does.
|
|
24
|
+
*
|
|
25
|
+
* Lora is fetched from Google Fonts, as the API pages do; the fallback stack
|
|
26
|
+
* carries the page on a machine with no network, which is the state a failed
|
|
27
|
+
* authorisation is sometimes in.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
export interface CallbackPage {
|
|
31
|
+
/** The focal line, set in Lora. A provider name, or the outcome itself. */
|
|
32
|
+
readonly heading: string;
|
|
33
|
+
/** The small line above it — "Connected" over the name of what was. */
|
|
34
|
+
readonly label?: string;
|
|
35
|
+
/** What the reader should do next. */
|
|
36
|
+
readonly detail: string;
|
|
37
|
+
readonly ok: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Escape text for an HTML body.
|
|
42
|
+
*
|
|
43
|
+
* `escapeXml` in `connectivity/transports/dav/xml.ts` would do, and `cli` is
|
|
44
|
+
* allowed to import `connectivity` — but a page renderer reaching into a DAV
|
|
45
|
+
* transport for a string function is a dependency nobody would defend. Five
|
|
46
|
+
* lines is cheaper than the coupling.
|
|
47
|
+
*
|
|
48
|
+
* This is load-bearing: `heading` carries a provider's `manifest.name`, and a
|
|
49
|
+
* custom provider supplies that from a YAML file of its own.
|
|
50
|
+
*/
|
|
51
|
+
function escapeHtml(value: string): string {
|
|
52
|
+
return value
|
|
53
|
+
.replace(/&/g, '&')
|
|
54
|
+
.replace(/</g, '<')
|
|
55
|
+
.replace(/>/g, '>')
|
|
56
|
+
.replace(/"/g, '"')
|
|
57
|
+
.replace(/'/g, ''');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Harmonised with the Lanes API's public pages, which are themselves harmonised
|
|
62
|
+
* with the transactional emails — same dashed card, same Lora, same footer, so
|
|
63
|
+
* the end of a connect flow reads as the same product as the invite that
|
|
64
|
+
* preceded it.
|
|
65
|
+
*/
|
|
66
|
+
const STYLE = `
|
|
67
|
+
:root { color-scheme: light dark; }
|
|
68
|
+
* { box-sizing: border-box; }
|
|
69
|
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
70
|
+
display: flex; align-items: center; justify-content: center;
|
|
71
|
+
min-height: 100vh; margin: 0; background: transparent; padding: 24px; }
|
|
72
|
+
.wrap { width: 100%; max-width: 460px; text-align: center; }
|
|
73
|
+
.card { background: transparent; border: 1px dashed rgba(120,113,108,0.35); border-radius: 10px;
|
|
74
|
+
padding: 48px 36px; }
|
|
75
|
+
.label { margin: 0 0 6px; font-size: 15px; font-weight: 500; letter-spacing: 0.01em; opacity: 0.6; }
|
|
76
|
+
h1 { font-family: "Lora", Georgia, "Times New Roman", serif; font-size: 28px; font-weight: 500;
|
|
77
|
+
letter-spacing: -0.01em; line-height: 1.3; margin: 0 0 18px; }
|
|
78
|
+
.detail { margin: 0; font-size: 15px; line-height: 1.6; opacity: 0.75; }
|
|
79
|
+
.icon { display: block; margin: 0 auto 18px; color: #059669; }
|
|
80
|
+
.footer { margin: 24px 0 0; }
|
|
81
|
+
.footer p { margin: 0; font-size: 12px; line-height: 1.5; opacity: 0.5; }
|
|
82
|
+
.footer a { color: inherit; text-decoration: underline; }
|
|
83
|
+
.err h1 { color: #A06060; }
|
|
84
|
+
@media (prefers-color-scheme: dark) {
|
|
85
|
+
.err h1 { color: #C08080; }
|
|
86
|
+
.icon { color: #34D399; }
|
|
87
|
+
}
|
|
88
|
+
`.trim();
|
|
89
|
+
|
|
90
|
+
/** The one-row footer below the card, identical to the one the API pages carry. */
|
|
91
|
+
const FOOTER =
|
|
92
|
+
'<div class="footer"><p>Your unfair advantage in parallel AI coding. ' +
|
|
93
|
+
'<a href="https://lanes.sh/">lanes.sh</a></p></div>';
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The success mark, above the label.
|
|
97
|
+
*
|
|
98
|
+
* The glyph and the emerald are the Lanes app's, so a connected provider looks
|
|
99
|
+
* the same wherever it is reported — lucide's `check` at #059669, lightened to
|
|
100
|
+
* #34D399 on a dark canvas because the darker green does not survive it.
|
|
101
|
+
*
|
|
102
|
+
* `aria-hidden` because the word underneath is the announcement; a screen
|
|
103
|
+
* reader hearing "Connected" does not also need to hear about a tick.
|
|
104
|
+
*/
|
|
105
|
+
const ICON =
|
|
106
|
+
'<svg class="icon" xmlns="http://www.w3.org/2000/svg" width="28" height="28" ' +
|
|
107
|
+
'viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
|
|
108
|
+
'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
|
|
109
|
+
'<path d="M20 6 9 17l-5-5"/></svg>';
|
|
110
|
+
|
|
111
|
+
export interface ApprovalPage {
|
|
112
|
+
/** What is asking. A client's self-reported name, or a stand-in. */
|
|
113
|
+
readonly client: string;
|
|
114
|
+
/** Where the code would be sent. The part of the request that cannot be faked. */
|
|
115
|
+
readonly redirectHost: string;
|
|
116
|
+
/** Hidden fields carrying the authorization request through the POST. */
|
|
117
|
+
readonly fields: Readonly<Record<string, string>>;
|
|
118
|
+
readonly action: string;
|
|
119
|
+
/** A previous attempt presented the wrong token. */
|
|
120
|
+
readonly retry: boolean;
|
|
121
|
+
/** The target this endpoint runs as, so the hint below names the right store. */
|
|
122
|
+
readonly target: string;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The one screen a remote client's authorization stops at.
|
|
127
|
+
*
|
|
128
|
+
* Same card as the page above, because it is the same product and the reader
|
|
129
|
+
* arrived here from a connector rather than from a terminal. What it asks for is
|
|
130
|
+
* the endpoint token — the string `lanes link outputs` prints — because that is
|
|
131
|
+
* already the proof of being the owner and inventing a second one would mean
|
|
132
|
+
* inventing a password to go with it.
|
|
133
|
+
*
|
|
134
|
+
* The hint names the target rather than leaving it out. Credentials are
|
|
135
|
+
* per-target, and the reader runs that command in a shell resolving a target of
|
|
136
|
+
* its own — `local` by default, which is the one store a deployed endpoint's
|
|
137
|
+
* token is never in. Omitting it sends them to fetch the wrong secret, and
|
|
138
|
+
* `outputs` mints a fresh one when that store is empty rather than saying so.
|
|
139
|
+
*
|
|
140
|
+
* `autocomplete="off"` and `type="password"` are not theatre: this form is
|
|
141
|
+
* submitted in whatever browser the phone opened, and a token remembered by a
|
|
142
|
+
* shared browser is a token in the hands of whoever borrows the phone.
|
|
143
|
+
*/
|
|
144
|
+
export function approvalPage(page: ApprovalPage): Response {
|
|
145
|
+
const hidden = Object.entries(page.fields)
|
|
146
|
+
.map(([name, value]) => `<input type="hidden" name="${escapeHtml(name)}" value="${escapeHtml(value)}">`)
|
|
147
|
+
.join('\n');
|
|
148
|
+
|
|
149
|
+
// The name is self-reported and the host is not, so the host is what the
|
|
150
|
+
// reader is asked to recognise. Registration is open by design, which means
|
|
151
|
+
// anything can call itself anything — but an impostor still has to nominate
|
|
152
|
+
// somewhere for the code to go, and that is on the screen.
|
|
153
|
+
const body = `
|
|
154
|
+
${page.retry ? '<p class="label err-text">That token was not accepted.</p>\n' : ''}<h1>Authorise ${escapeHtml(page.client)}?</h1>
|
|
155
|
+
<p class="detail">It will be sent back to <strong>${escapeHtml(page.redirectHost)}</strong>, and will be able to reach every profile this endpoint serves, within the policy each one declares.</p>
|
|
156
|
+
<form method="post" action="${escapeHtml(page.action)}">
|
|
157
|
+
${hidden}
|
|
158
|
+
<input class="field" type="password" name="token" placeholder="Endpoint token" autocomplete="off" autofocus required>
|
|
159
|
+
<button class="go" type="submit">Approve</button>
|
|
160
|
+
</form>
|
|
161
|
+
<p class="detail small">Printed by <code>lanes link outputs --show --target ${escapeHtml(page.target)}</code>.</p>`;
|
|
162
|
+
|
|
163
|
+
return page.retry ? shell(body, 'Authorise', 401) : shell(body, 'Authorise', 200);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function shell(inner: string, title: string, status: number): Response {
|
|
167
|
+
return new Response(
|
|
168
|
+
`<!doctype html>
|
|
169
|
+
<html lang="en">
|
|
170
|
+
<head>
|
|
171
|
+
<meta charset="utf-8">
|
|
172
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
173
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
174
|
+
<link href="https://fonts.googleapis.com/css2?family=Lora:wght@400;500&display=swap" rel="stylesheet">
|
|
175
|
+
<title>${escapeHtml(title)}</title>
|
|
176
|
+
<style>
|
|
177
|
+
${STYLE}
|
|
178
|
+
${FORM_STYLE}
|
|
179
|
+
</style>
|
|
180
|
+
</head>
|
|
181
|
+
<body>
|
|
182
|
+
<div class="wrap">
|
|
183
|
+
<div class="card">
|
|
184
|
+
${inner}
|
|
185
|
+
</div>
|
|
186
|
+
${FOOTER}
|
|
187
|
+
</div>
|
|
188
|
+
</body>
|
|
189
|
+
</html>`,
|
|
190
|
+
{
|
|
191
|
+
status,
|
|
192
|
+
headers: {
|
|
193
|
+
'content-type': 'text/html; charset=utf-8',
|
|
194
|
+
// One of these pages asks for the endpoint token in a password field.
|
|
195
|
+
// Framing it is the cheap half of a UI-redress attack, and nothing here
|
|
196
|
+
// is ever meant to be embedded — so refuse it in both spellings, since
|
|
197
|
+
// `frame-ancestors` is the one that is actually specified and
|
|
198
|
+
// `X-Frame-Options` is the one older browsers obey.
|
|
199
|
+
'content-security-policy': "frame-ancestors 'none'",
|
|
200
|
+
'x-frame-options': 'DENY',
|
|
201
|
+
// The URL carries a `client_id` and a `redirect_uri`; neither belongs in
|
|
202
|
+
// a Referer sent to whatever the page links out to.
|
|
203
|
+
'referrer-policy': 'no-referrer',
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const FORM_STYLE = `
|
|
210
|
+
.field { width: 100%; margin: 20px 0 12px; padding: 11px 13px; font: inherit; font-size: 15px;
|
|
211
|
+
color: inherit; background: transparent; border: 1px solid rgba(120,113,108,0.45);
|
|
212
|
+
border-radius: 7px; }
|
|
213
|
+
.field:focus { outline: none; border-color: rgba(120,113,108,0.9); }
|
|
214
|
+
.go { width: 100%; padding: 11px 13px; font: inherit; font-size: 15px; font-weight: 500;
|
|
215
|
+
color: inherit; background: transparent; border: 1px solid rgba(120,113,108,0.55);
|
|
216
|
+
border-radius: 7px; cursor: pointer; }
|
|
217
|
+
.go:hover { border-color: rgba(120,113,108,0.95); }
|
|
218
|
+
.small { margin-top: 14px; font-size: 12px; opacity: 0.55; }
|
|
219
|
+
.small code { font-size: 12px; }
|
|
220
|
+
.err-text { color: #A06060; opacity: 1; }
|
|
221
|
+
@media (prefers-color-scheme: dark) { .err-text { color: #C08080; } }
|
|
222
|
+
`.trim();
|
|
223
|
+
|
|
224
|
+
export function completionPage(page: CallbackPage): Response {
|
|
225
|
+
const { heading, label, detail, ok } = page;
|
|
226
|
+
|
|
227
|
+
// The tab strip truncates to a few characters, and the word that has to
|
|
228
|
+
// survive that is the outcome — not the name of what was connected.
|
|
229
|
+
const title = escapeHtml(label ?? heading);
|
|
230
|
+
|
|
231
|
+
return new Response(
|
|
232
|
+
`<!doctype html>
|
|
233
|
+
<html lang="en">
|
|
234
|
+
<head>
|
|
235
|
+
<meta charset="utf-8">
|
|
236
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
237
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
238
|
+
<link href="https://fonts.googleapis.com/css2?family=Lora:wght@400;500&display=swap" rel="stylesheet">
|
|
239
|
+
<title>${title}</title>
|
|
240
|
+
<style>
|
|
241
|
+
${STYLE}
|
|
242
|
+
</style>
|
|
243
|
+
</head>
|
|
244
|
+
<body>
|
|
245
|
+
<div class="wrap">
|
|
246
|
+
<div class="card${ok ? '' : ' err'}">
|
|
247
|
+
${ok ? `${ICON}\n` : ''}${label ? `<p class="label">${escapeHtml(label)}</p>\n` : ''}<h1>${escapeHtml(heading)}</h1>
|
|
248
|
+
<p class="detail">${escapeHtml(detail)}</p>
|
|
249
|
+
</div>
|
|
250
|
+
${FOOTER}
|
|
251
|
+
</div>
|
|
252
|
+
</body>
|
|
253
|
+
</html>`,
|
|
254
|
+
{ status: ok ? 200 : 400, headers: { 'content-type': 'text/html; charset=utf-8' } },
|
|
255
|
+
);
|
|
256
|
+
}
|