@lanes-sh/link 0.2.1 → 0.3.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.
Files changed (139) hide show
  1. package/README.md +21 -8
  2. package/instructions/skills/lanes-link/SKILL.md +59 -14
  3. package/package.json +1 -1
  4. package/src/auth/index.ts +3 -1
  5. package/src/auth/oauth/metadata.ts +83 -9
  6. package/src/auth/oauth/redirects.ts +70 -0
  7. package/src/auth/oauth/server.ts +49 -69
  8. package/src/auth/oauth/store.ts +19 -5
  9. package/src/cli/argv.ts +50 -0
  10. package/src/cli/brand.ts +178 -0
  11. package/src/cli/callback-page.ts +108 -128
  12. package/src/cli/commands/connect/accounts.ts +5 -0
  13. package/src/cli/commands/connect/assertion.ts +187 -0
  14. package/src/cli/commands/connect/authorise.ts +61 -17
  15. package/src/cli/commands/connect/client.ts +37 -9
  16. package/src/cli/commands/connect/discover.ts +94 -0
  17. package/src/cli/commands/connect/family.ts +72 -0
  18. package/src/cli/commands/connect/index.ts +113 -115
  19. package/src/cli/commands/connect/method.ts +237 -0
  20. package/src/cli/commands/connect/outcome.ts +42 -1
  21. package/src/cli/commands/connect/pasted-token.ts +66 -0
  22. package/src/cli/commands/connect/requirements.ts +60 -8
  23. package/src/cli/commands/connect/setup.ts +16 -5
  24. package/src/cli/commands/connect/target-note.ts +34 -0
  25. package/src/cli/commands/identity.ts +258 -0
  26. package/src/cli/commands/knowledge/index.ts +390 -0
  27. package/src/cli/commands/knowledge/migrate.ts +180 -0
  28. package/src/cli/commands/knowledge/setup.ts +144 -0
  29. package/src/cli/commands/knowledge.ts +10 -0
  30. package/src/cli/commands/mcp/harnesses.ts +16 -2
  31. package/src/cli/commands/mcp/register.ts +9 -1
  32. package/src/cli/commands/mcp/stdio.ts +21 -0
  33. package/src/cli/commands/operate/dashboard.ts +107 -0
  34. package/src/cli/commands/operate/findings.ts +151 -0
  35. package/src/cli/commands/operate/inspect.ts +56 -158
  36. package/src/cli/commands/operate/outputs.ts +38 -11
  37. package/src/cli/commands/operate/serve.ts +3 -0
  38. package/src/cli/commands/operate/token.ts +1 -1
  39. package/src/cli/commands/operate.ts +2 -0
  40. package/src/cli/commands/profile/declare.ts +154 -0
  41. package/src/cli/commands/profile/removal.ts +17 -0
  42. package/src/cli/commands/profile.ts +83 -35
  43. package/src/cli/commands/setup.ts +22 -6
  44. package/src/cli/commands/target.ts +65 -83
  45. package/src/cli/config-edit.ts +48 -144
  46. package/src/cli/config-repair.ts +186 -0
  47. package/src/cli/dashboard-page.ts +284 -0
  48. package/src/cli/dashboard-shell.ts +125 -0
  49. package/src/cli/identity.ts +12 -1
  50. package/src/cli/main.ts +68 -4
  51. package/src/cli/oauth-callback.ts +187 -0
  52. package/src/cli/oauth-exchange.ts +57 -15
  53. package/src/cli/oauth.ts +67 -177
  54. package/src/cli/output.ts +21 -5
  55. package/src/cli/provider-marks.ts +45 -0
  56. package/src/cli/runtime/open.ts +67 -49
  57. package/src/cli/runtime/registry.ts +60 -2
  58. package/src/cli/runtime/select.ts +26 -13
  59. package/src/cli/runtime/vault.ts +61 -0
  60. package/src/cli/runtime.ts +2 -1
  61. package/src/cli/selection.ts +357 -0
  62. package/src/cli/usage.ts +32 -9
  63. package/src/connectivity/auth/README.md +7 -1
  64. package/src/connectivity/auth/basic/index.ts +1 -1
  65. package/src/connectivity/auth/index.ts +14 -0
  66. package/src/connectivity/auth/oauth-authcode/broker.ts +26 -0
  67. package/src/connectivity/auth/oauth-authcode/index.ts +16 -2
  68. package/src/connectivity/auth/oauth-authcode/provider.ts +1 -1
  69. package/src/connectivity/auth/oauth-authcode/refresh.ts +3 -3
  70. package/src/connectivity/auth/oauth-jwt/README.md +33 -0
  71. package/src/connectivity/auth/oauth-jwt/index.ts +237 -0
  72. package/src/connectivity/auth/oauth-jwt/key.ts +148 -0
  73. package/src/connectivity/auth/resolve.ts +1 -1
  74. package/src/connectivity/auth/token.ts +11 -0
  75. package/src/connectivity/index.ts +2 -0
  76. package/src/connectivity/manifest/auth.ts +99 -2
  77. package/src/connectivity/manifest/identity.ts +12 -0
  78. package/src/connectivity/manifest/index.ts +3 -1
  79. package/src/connectivity/manifest/provider.ts +37 -8
  80. package/src/connectivity/manifest/requirements.ts +109 -6
  81. package/src/deployments/adapters/filesystem.ts +10 -1
  82. package/src/deployments/adapters/github-api.ts +106 -0
  83. package/src/deployments/adapters/github-commit.ts +103 -0
  84. package/src/deployments/adapters/github-repo.ts +356 -0
  85. package/src/deployments/adapters/github-testing.ts +258 -0
  86. package/src/deployments/adapters/github.ts +125 -0
  87. package/src/deployments/deploy.ts +48 -19
  88. package/src/deployments/driver.ts +8 -1
  89. package/src/deployments/gcp/driver.ts +9 -1
  90. package/src/deployments/gcp/survey.ts +3 -0
  91. package/src/deployments/knowledge.ts +119 -0
  92. package/src/deployments/prepare.ts +2 -2
  93. package/src/deployments/servable.ts +81 -0
  94. package/src/deployments/target.ts +3 -2
  95. package/src/deployments/upload.ts +2 -1
  96. package/src/dispatch/dispatch.ts +1 -1
  97. package/src/profile/authorization.ts +13 -4
  98. package/src/profile/identity.ts +60 -0
  99. package/src/profile/index.ts +15 -5
  100. package/src/profile/knowledge.ts +124 -0
  101. package/src/profile/load.ts +17 -5
  102. package/src/profile/primitives.ts +24 -1
  103. package/src/profile/schema.ts +72 -4
  104. package/src/profile/targets.ts +74 -114
  105. package/src/profile/workspace.ts +71 -84
  106. package/src/providers/google/calendar/index.ts +2 -0
  107. package/src/providers/google/contacts/index.ts +2 -0
  108. package/src/providers/google/docs/index.ts +2 -0
  109. package/src/providers/google/drive/index.ts +2 -0
  110. package/src/providers/google/gmail/index.ts +2 -0
  111. package/src/providers/google/gmail-imap/index.ts +125 -0
  112. package/src/providers/google/index.ts +2 -1
  113. package/src/providers/google/shared/oauth.ts +18 -6
  114. package/src/providers/google/shared/service-account.ts +110 -0
  115. package/src/providers/google/shared/setup.ts +5 -2
  116. package/src/providers/google/sheets/index.ts +2 -0
  117. package/src/providers/google/tasks/index.ts +2 -0
  118. package/src/providers/identity/provider.ts +166 -0
  119. package/src/providers/index.ts +3 -0
  120. package/src/providers/owner.ts +10 -2
  121. package/src/providers/scopes.ts +2 -0
  122. package/src/providers/setup/plan.ts +31 -9
  123. package/src/providers/setup/provider.ts +23 -0
  124. package/src/providers/slack/index.ts +81 -33
  125. package/src/providers/slack/oauth.ts +103 -0
  126. package/src/providers/slack/scopes.ts +37 -0
  127. package/src/server/container.ts +18 -1
  128. package/src/server/cors.ts +252 -0
  129. package/src/server/dashboard.ts +208 -0
  130. package/src/server/endpoint.ts +57 -4
  131. package/src/server/generation.ts +1 -0
  132. package/src/server/generations.ts +13 -2
  133. package/src/server/harness.ts +20 -3
  134. package/src/server/index.ts +76 -12
  135. package/src/server/mcp/build.ts +1 -1
  136. package/src/server/mcp/index.ts +1 -0
  137. package/src/server/mcp/instructions.ts +62 -5
  138. package/src/server/mcp/visibility.ts +42 -0
  139. package/src/stores/blobs/route.ts +123 -0
package/src/cli/argv.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { GlobalFlags } from './runtime.ts';
2
2
  import type { OwnerFlags } from './commands/owner.ts';
3
+ import type { KnowledgeFlags } from './commands/knowledge.ts';
3
4
 
4
5
  /**
5
6
  * Turning argv into a command path and a flag bag.
@@ -63,6 +64,32 @@ export function text(flags: Flags, name: string): string | undefined {
63
64
  return typeof value === 'string' ? value : undefined;
64
65
  }
65
66
 
67
+ /**
68
+ * Every value given for a flag, in order.
69
+ *
70
+ * `profile add --target local --target cloud` is the one command where a flag
71
+ * legitimately repeats: it is declaring a list, not selecting one thing. The
72
+ * parser keeps the last value in `flags`, so this re-reads argv rather than
73
+ * changing that shape for every other flag's sake.
74
+ */
75
+ export function all(argv: readonly string[], name: string): string[] {
76
+ const values: string[] = [];
77
+
78
+ for (let i = 0; i < argv.length; i++) {
79
+ const token = argv[i]!;
80
+ if (token === `--${name}`) {
81
+ const next = argv[i + 1];
82
+ if (next !== undefined && !next.startsWith('--')) values.push(next);
83
+ continue;
84
+ }
85
+ if (token.startsWith(`--${name}=`)) values.push(token.slice(name.length + 3));
86
+ }
87
+
88
+ // Comma-separated is the other spelling people reach for, and refusing it
89
+ // would be a refusal with no reason behind it.
90
+ return values.flatMap((value) => value.split(',').map((part) => part.trim())).filter(Boolean);
91
+ }
92
+
66
93
  /** The flags every command accepts. */
67
94
  export function globalFlags(flags: Flags): GlobalFlags {
68
95
  return {
@@ -92,3 +119,26 @@ export function ownerFlags(flags: Flags): OwnerFlags {
92
119
  yes: flags['yes'] === true,
93
120
  };
94
121
  }
122
+
123
+ /**
124
+ * `lanes link knowledge`'s flags.
125
+ *
126
+ * Here for the reason `ownerFlags` is: the kebab-case spellings belong in the
127
+ * one place that parses argv, so `--allow-public` is written once rather than
128
+ * once per reader.
129
+ */
130
+ export function knowledgeFlags(flags: Flags): KnowledgeFlags {
131
+ return {
132
+ ...globalFlags(flags),
133
+ repo: text(flags, 'repo'),
134
+ branch: text(flags, 'branch'),
135
+ path: text(flags, 'path'),
136
+ // `--migrate`, `--no-migrate`, or neither — see `KnowledgeFlags.migrate`.
137
+ migrate: flags['migrate'] === true ? true : flags['no-migrate'] === true ? false : undefined,
138
+ keep: flags['keep'] === true,
139
+ allowPublic: flags['allow-public'] === true,
140
+ replace: flags['replace'] === true,
141
+ yes: flags['yes'] === true,
142
+ json: flags['json'] === true,
143
+ };
144
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * The Lanes design tokens, and the pieces every page this repository serves is
3
+ * built from.
4
+ *
5
+ * Three surfaces render HTML — the authorization consent screen, the page a
6
+ * connect flow lands on, and the dashboard — and until this file existed each
7
+ * carried its own approximation of the brand. They had drifted: two greens and
8
+ * two ambers that are not tokens at all, nine different alphas standing in for
9
+ * one border colour, a destructive red with a dark variant the token does not
10
+ * have, and the system font stack where Geist belongs.
11
+ *
12
+ * Values are transcribed from https://lanes.sh/design/foundations. The rules
13
+ * they express, which the CSS below encodes rather than restates:
14
+ *
15
+ * - **Gold is the only accent.** "Gold for positive, neutral tokens otherwise."
16
+ * That is why there is no green here: a connection that works is `positive`,
17
+ * which is gold, and everything else is one of two neutrals. `--destructive`
18
+ * is for errors, not for a state that merely needs attention.
19
+ * - **Two weights, 400 and 500.** Nothing else is available in the loaded faces,
20
+ * so a heavier rule would silently synthesise.
21
+ * - **Lora heads, Geist speaks, Geist Mono for code and eyebrow labels.**
22
+ *
23
+ * `--border`, `--accent-gold` and `--destructive` are constant across themes.
24
+ * Only the five neutrals swap, which is why the dark block below is five lines
25
+ * rather than a second stylesheet.
26
+ */
27
+
28
+ /**
29
+ * Escape text for an HTML body.
30
+ *
31
+ * `escapeXml` in `connectivity/transports/dav/xml.ts` would do, and `cli` is
32
+ * allowed to import `connectivity` — but a page renderer reaching into a DAV
33
+ * transport for a string function is a dependency nobody would defend. Five
34
+ * lines is cheaper than the coupling.
35
+ *
36
+ * Load-bearing: these pages render a provider's `manifest.name`, and a custom
37
+ * provider supplies that from a YAML file of its own.
38
+ */
39
+ export function escapeHtml(value: string): string {
40
+ return value
41
+ .replace(/&/g, '&amp;')
42
+ .replace(/</g, '&lt;')
43
+ .replace(/>/g, '&gt;')
44
+ .replace(/"/g, '&quot;')
45
+ .replace(/'/g, '&#39;');
46
+ }
47
+
48
+ /**
49
+ * One request for all three faces.
50
+ *
51
+ * `preconnect` to both hosts because the stylesheet and the font files it names
52
+ * are served from different origins, and the second lookup is otherwise not
53
+ * started until the first has parsed.
54
+ */
55
+ export const FONTS =
56
+ '<link rel="preconnect" href="https://fonts.googleapis.com">\n' +
57
+ '<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>\n' +
58
+ '<link href="https://fonts.googleapis.com/css2?family=Geist+Mono:wght@400;500' +
59
+ '&amp;family=Geist:wght@400;500&amp;family=Lora:wght@400;500&amp;display=swap" rel="stylesheet">';
60
+
61
+ /** The one-row footer, identical to the one the Lanes API's own pages carry. */
62
+ export const FOOTER =
63
+ '<div class="footer"><p>Your unfair advantage in parallel AI coding. ' +
64
+ '<a href="https://lanes.sh/">lanes.sh</a></p></div>';
65
+
66
+ /**
67
+ * The tokens, and the base every page shares.
68
+ *
69
+ * The background is **painted**, which reverses what these pages used to do.
70
+ * The old note read "nothing is painted" — a transparent body under
71
+ * `color-scheme: light dark` so the browser supplied its own canvas — and it
72
+ * was written to fix a hardcoded `#0d1117` that showed as a black rectangle on
73
+ * a light machine. The design system settles that differently: it names a
74
+ * background for each mode, so the fix is to paint the right one rather than
75
+ * none. `color-scheme` stays, because form controls and scrollbars still need
76
+ * to know which mode they are in.
77
+ */
78
+ export const TOKENS = `
79
+ :root {
80
+ color-scheme: light dark;
81
+ --background: #EBEAE7;
82
+ --card: #F0EFEC;
83
+ --muted: #E2E1DD;
84
+ --foreground: #171717;
85
+ --muted-foreground: #202329;
86
+ --border: rgba(120,113,108,0.2);
87
+ --accent-gold: #A1845A;
88
+ --destructive: #A06060;
89
+ /* The two tints the badge variants need. Spelled as rgba rather than
90
+ color-mix because the consent screen opens in whatever browser a phone
91
+ happens to use, and both accents are constant across themes anyway. */
92
+ --gold-fill: rgba(161,132,90,0.1);
93
+ --gold-ring: rgba(161,132,90,0.25);
94
+ --destructive-fill: rgba(160,96,96,0.1);
95
+ --destructive-ring: rgba(160,96,96,0.25);
96
+ --sans: "Geist", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
97
+ --serif: "Lora", Georgia, "Times New Roman", serif;
98
+ --mono: "Geist Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
99
+ }
100
+ @media (prefers-color-scheme: dark) {
101
+ :root {
102
+ --background: #171717;
103
+ --card: #292524;
104
+ --muted: #202329;
105
+ --foreground: #EBEAE7;
106
+ --muted-foreground: #A8A29E;
107
+ }
108
+ }
109
+ * { box-sizing: border-box; }
110
+ body { margin: 0; font-family: var(--sans); font-weight: 400;
111
+ background: var(--background); color: var(--foreground); }
112
+ h1, h2, h3 { font-family: var(--serif); font-weight: 500; letter-spacing: -0.01em; margin: 0; }
113
+ h1 { font-size: 28px; line-height: 1.3; }
114
+ code, .mono { font-family: var(--mono); }
115
+ a { color: inherit; }
116
+
117
+ /* Eyebrow: 11px, uppercase, wide tracking, muted — and mono, which is where the
118
+ design system puts label type. */
119
+ .eyebrow { font-family: var(--mono); font-size: 11px; font-weight: 400;
120
+ letter-spacing: 0.09em; text-transform: uppercase; color: var(--muted-foreground);
121
+ opacity: 0.75; }
122
+
123
+ /* Surfaces are dashed and 10px; controls are 6px; pills are round. */
124
+ .surface { background: var(--card); border: 1px dashed var(--border); border-radius: 10px; }
125
+
126
+ /* The four badge variants, as the design system defines them: gold for
127
+ positive, neutral tokens otherwise, destructive for an error only. The ring
128
+ is an inset shadow so it costs no layout, exactly as \`ring-inset\` does. */
129
+ .pill { display: inline-flex; align-items: center; flex: none; white-space: nowrap;
130
+ font-size: 11px; font-weight: 500; letter-spacing: 0.02em;
131
+ padding: 2px 8px; border-radius: 999px; box-shadow: inset 0 0 0 1px var(--border); }
132
+ .pill.positive { background: var(--gold-fill); color: var(--accent-gold);
133
+ box-shadow: inset 0 0 0 1px var(--gold-ring); }
134
+ .pill.neutral { background: var(--muted); color: var(--muted-foreground); }
135
+ .pill.quiet { background: transparent; color: var(--muted-foreground); opacity: 0.7; }
136
+ .pill.negative { background: var(--destructive-fill); color: var(--destructive);
137
+ box-shadow: inset 0 0 0 1px var(--destructive-ring); }
138
+
139
+ /* Ghost is the variant every button on these pages is: muted until it is
140
+ wanted, and never gold, because gold is reserved for saying a thing is good. */
141
+ .btn { font: inherit; font-size: 12px; font-weight: 500; line-height: 1;
142
+ padding: 6px 9px; border-radius: 6px; cursor: pointer;
143
+ background: transparent; color: var(--muted-foreground);
144
+ border: 1px solid var(--border); }
145
+ .btn:hover, .btn:focus-visible { color: var(--foreground); background: var(--muted); }
146
+
147
+ .footer { margin: 40px 0 0; text-align: center; }
148
+ .footer p { margin: 0; font-size: 12px; line-height: 1.5; color: var(--muted-foreground); opacity: 0.75; }
149
+ .footer a { text-decoration: underline; }
150
+ `.trim();
151
+
152
+ /**
153
+ * What every page these modules serve loads, and nothing else.
154
+ *
155
+ * The font stylesheet and the faces it names are the only two origins any of
156
+ * them reaches; `default-src 'none'` closes the rest. No `script-src`, because
157
+ * these pages have no script — the dashboard, which has one listener, extends
158
+ * this rather than replacing it.
159
+ */
160
+ export const PAGE_CSP =
161
+ "frame-ancestors 'none'; default-src 'none'; " +
162
+ "style-src 'unsafe-inline' https://fonts.googleapis.com; " +
163
+ 'font-src https://fonts.gstatic.com';
164
+
165
+ /**
166
+ * Headers every page here answers with.
167
+ *
168
+ * `frame-ancestors` in both spellings: one of these pages asks for the endpoint
169
+ * token and another lists the owner's accounts, and framing either is the cheap
170
+ * half of a UI-redress attack. The URLs carry a `client_id`, a `redirect_uri`,
171
+ * or a one-time key, none of which belongs in a Referer.
172
+ */
173
+ export const PAGE_HEADERS: Readonly<Record<string, string>> = {
174
+ 'content-type': 'text/html; charset=utf-8',
175
+ 'content-security-policy': PAGE_CSP,
176
+ 'x-frame-options': 'DENY',
177
+ 'referrer-policy': 'no-referrer',
178
+ };
@@ -1,32 +1,33 @@
1
1
  /**
2
- * The page the browser lands on when a connect flow ends.
2
+ * The two pages a browser reaches on its way through an authorization.
3
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.
4
+ * `approvalPage` is the consent screen a remote MCP client stops at;
5
+ * `completionPage` is where a provider connect flow lands. For a few seconds
6
+ * each of them is the whole product, so both are the product's own page: the
7
+ * dashed card, the Lora heading, and the shared one-row footer that the Lanes
8
+ * API serves after a workspace invite or an email verification.
10
9
  *
11
- * Two decisions are worth keeping:
10
+ * Everything visual now comes from `brand.ts`. Two decisions this file used to
11
+ * record have been settled the other way by the design system, and are worth
12
+ * naming because both look like regressions otherwise:
12
13
  *
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.
14
+ * - **The canvas is painted.** This used to be transparent under
15
+ * `color-scheme: light dark`, so the browser supplied its own a fix for a
16
+ * hardcoded `#0d1117` that read as a black rectangle on a light machine. The
17
+ * system names a background per mode, so the fix is now to paint the right
18
+ * one. `color-scheme` stays, for form controls and scrollbars.
19
+ * - **The success mark is gold, not green.** It was lucide's `check` at
20
+ * `#059669`, chosen to match the tick the Lanes app puts against a connected
21
+ * integration. The design system is explicit that gold is the only accent and
22
+ * "gold for positive, neutral tokens otherwise", so the green was the odd one
23
+ * out rather than the match it was meant to be.
24
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.
25
+ * What has not changed is that colour is reserved for status and spent nowhere
26
+ * else not on emphasis, and not on the heading.
28
27
  */
29
28
 
29
+ import { escapeHtml, FONTS, FOOTER, PAGE_CSP, PAGE_HEADERS, TOKENS } from './brand.ts';
30
+
30
31
  export interface CallbackPage {
31
32
  /** The focal line, set in Lora. A provider name, or the outcome itself. */
32
33
  readonly heading: string;
@@ -38,69 +39,29 @@ export interface CallbackPage {
38
39
  }
39
40
 
40
41
  /**
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, '&amp;')
54
- .replace(/</g, '&lt;')
55
- .replace(/>/g, '&gt;')
56
- .replace(/"/g, '&quot;')
57
- .replace(/'/g, '&#39;');
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.
42
+ * The card, centred. Everything else is `brand.ts`.
65
43
  */
66
44
  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; }
45
+ body { display: flex; align-items: center; justify-content: center;
46
+ min-height: 100vh; padding: 24px; }
72
47
  .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
- }
48
+ .card { padding: 48px 36px; }
49
+ .label { margin: 0 0 6px; font-size: 15px; font-weight: 500; color: var(--muted-foreground); }
50
+ h1 { margin: 0 0 18px; }
51
+ .detail { margin: 0; font-size: 15px; line-height: 1.6; color: var(--muted-foreground); }
52
+ .icon { display: block; margin: 0 auto 18px; color: var(--accent-gold); }
53
+ .err h1 { color: var(--destructive); }
88
54
  `.trim();
89
55
 
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
56
  /**
96
57
  * The success mark, above the label.
97
58
  *
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.
59
+ * lucide's `check`, in `--accent-gold` which is what "gold for positive"
60
+ * means when the positive thing is a connection that now works. It carries no
61
+ * colour of its own; `.icon` sets it, so it follows the token if the token moves.
101
62
  *
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.
63
+ * `aria-hidden` because the word underneath is the announcement; a screen reader
64
+ * hearing "Connected" does not also need to hear about a tick.
104
65
  */
105
66
  const ICON =
106
67
  '<svg class="icon" xmlns="http://www.w3.org/2000/svg" width="28" height="28" ' +
@@ -158,49 +119,70 @@ ${hidden}
158
119
  <input class="field" type="password" name="token" placeholder="Endpoint token" autocomplete="off" autofocus required>
159
120
  <button class="go" type="submit">Approve</button>
160
121
  </form>
161
- <p class="detail small">Printed by <code>lanes link outputs --show --target ${escapeHtml(page.target)}</code>.</p>`;
122
+ <p class="small"><code>lanes link outputs --show --target ${escapeHtml(page.target)}</code></p>`;
162
123
 
163
- return page.retry ? shell(body, 'Authorise', 401) : shell(body, 'Authorise', 200);
124
+ return shell(body, 'Authorise', page.retry ? 401 : 200, '', SUBMIT_SPINNER);
164
125
  }
165
126
 
166
- function shell(inner: string, title: string, status: number): Response {
127
+ /**
128
+ * What runs while the approval is in flight.
129
+ *
130
+ * Approving is a round trip to an authorization server, and until it returns the
131
+ * page looks exactly as it did before the click — so the honest reading is that
132
+ * nothing happened, and the second click is the one that produces a duplicated
133
+ * request. The button disables itself, which is the part that matters; the
134
+ * spinner is what says why.
135
+ *
136
+ * The cost is real and worth naming: this is the one page that asks for the
137
+ * endpoint token, and an inline listener means its policy admits inline script.
138
+ * It is the minimum that does the job — one listener, no interpolation, nothing
139
+ * read from the page — and the alternative, a static file, is an asset pipeline
140
+ * this repository does not have.
141
+ */
142
+ const SUBMIT_SPINNER = `
143
+ document.querySelector('form').addEventListener('submit', function (event) {
144
+ var button = event.currentTarget.querySelector('.go');
145
+ button.classList.add('busy');
146
+ button.disabled = true;
147
+ });
148
+ `.trim();
149
+
150
+ function shell(
151
+ inner: string,
152
+ title: string,
153
+ status: number,
154
+ cardClass = '',
155
+ script = '',
156
+ ): Response {
167
157
  return new Response(
168
158
  `<!doctype html>
169
159
  <html lang="en">
170
160
  <head>
171
161
  <meta charset="utf-8">
172
162
  <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&amp;display=swap" rel="stylesheet">
163
+ ${FONTS}
175
164
  <title>${escapeHtml(title)}</title>
176
165
  <style>
166
+ ${TOKENS}
177
167
  ${STYLE}
178
168
  ${FORM_STYLE}
179
169
  </style>
180
170
  </head>
181
171
  <body>
182
172
  <div class="wrap">
183
- <div class="card">
173
+ <div class="card surface${cardClass}">
184
174
  ${inner}
185
175
  </div>
186
176
  ${FOOTER}
187
177
  </div>
178
+ ${script ? `<script>\n${script}\n</script>` : ''}
188
179
  </body>
189
180
  </html>`,
190
181
  {
191
182
  status,
192
183
  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',
184
+ ...PAGE_HEADERS,
185
+ ...(script ? { 'content-security-policy': `${PAGE_CSP}; script-src 'unsafe-inline'` } : {}),
204
186
  },
205
187
  },
206
188
  );
@@ -208,17 +190,30 @@ ${FOOTER}
208
190
 
209
191
  const FORM_STYLE = `
210
192
  .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); }
193
+ color: inherit; background: var(--background); border: 1px solid var(--border);
194
+ border-radius: 6px; }
195
+ .field:focus { outline: none; border-color: var(--accent-gold); }
214
196
  .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; }
197
+ color: var(--foreground); background: transparent; border: 1px solid var(--border);
198
+ border-radius: 6px; cursor: pointer; }
199
+ .go:hover { background: var(--muted); }
200
+ .small { margin-top: 16px; font-size: 12px; color: var(--muted-foreground); opacity: 0.8; }
219
201
  .small code { font-size: 12px; }
220
- .err-text { color: #A06060; opacity: 1; }
221
- @media (prefers-color-scheme: dark) { .err-text { color: #C08080; } }
202
+ .err-text { color: var(--destructive); }
203
+
204
+ /* The button, mid-flight. The label goes transparent rather than away, so the
205
+ button keeps the width it had and the card does not reflow under the cursor. */
206
+ .go.busy { color: transparent; position: relative; pointer-events: none; }
207
+ .go.busy::after { content: ''; position: absolute; inset: 0; margin: auto;
208
+ width: 15px; height: 15px; border-radius: 50%;
209
+ border: 2px solid var(--border); border-top-color: var(--foreground);
210
+ animation: spin 0.6s linear infinite; }
211
+ @keyframes spin { to { transform: rotate(360deg); } }
212
+ /* Monochrome deliberately: gold says a thing turned out well, and a request in
213
+ flight has not turned out yet. */
214
+ @media (prefers-reduced-motion: reduce) {
215
+ .go.busy::after { animation-duration: 2.4s; }
216
+ }
222
217
  `.trim();
223
218
 
224
219
  export function completionPage(page: CallbackPage): Response {
@@ -226,31 +221,16 @@ export function completionPage(page: CallbackPage): Response {
226
221
 
227
222
  // The tab strip truncates to a few characters, and the word that has to
228
223
  // survive that is the outcome — not the name of what was connected.
229
- const title = escapeHtml(label ?? heading);
224
+ const title = label ?? heading;
230
225
 
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&amp;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
- );
226
+ const body =
227
+ `${ok ? `${ICON}\n` : ''}` +
228
+ `${label ? `<p class="label">${escapeHtml(label)}</p>\n` : ''}` +
229
+ `<h1>${escapeHtml(heading)}</h1>` +
230
+ `<p class="detail">${escapeHtml(detail)}</p>`;
231
+
232
+ // Through the same shell as the consent screen, rather than a second copy of
233
+ // the document. They were already meant to be one page; only the error class
234
+ // on the card differs.
235
+ return shell(body, title, ok ? 200 : 400, ok ? '' : ' err');
256
236
  }
@@ -92,3 +92,8 @@ export async function moveCredential(
92
92
  await credentials.set(to, value);
93
93
  await credentials.delete(from);
94
94
  }
95
+
96
+ /** What a spec that named an account rather than a provider turned out to mean. */
97
+ export function familyNote(providerId: string, family: readonly string[]): string {
98
+ return `${providerId} is ${family.length} services on one account: ${family.join(', ')}`;
99
+ }