dsh-qr-share 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/CHANGELOG.md +54 -0
- package/LICENSE +21 -0
- package/README.md +146 -0
- package/README.zh.md +109 -0
- package/THIRD_PARTY_NOTICES.md +38 -0
- package/cordis.patch.yml +22 -0
- package/dsh.plugin.json +16 -0
- package/lib/client-registry.js +2804 -0
- package/lib/client.js +2804 -0
- package/lib/index.js +78 -0
- package/lib/invariant.js +24 -0
- package/lib/types/client/QrButton.d.ts +13 -0
- package/lib/types/client/QrDialog.d.ts +20 -0
- package/lib/types/client/api.d.ts +17 -0
- package/lib/types/client/index.d.ts +32 -0
- package/lib/types/client/locales.d.ts +16 -0
- package/lib/types/config.d.ts +28 -0
- package/lib/types/context-types.d.ts +34 -0
- package/lib/types/index.d.ts +14 -0
- package/lib/types/invariant.d.ts +9 -0
- package/package.json +135 -0
- package/src/client/QrButton.tsx +49 -0
- package/src/client/QrDialog.tsx +105 -0
- package/src/client/api.ts +41 -0
- package/src/client/index.tsx +107 -0
- package/src/client/locales.ts +38 -0
- package/src/client/styles.module.css +158 -0
- package/src/config.ts +37 -0
- package/src/context-types.ts +39 -0
- package/src/index.ts +107 -0
- package/src/invariant.ts +27 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-qr-share client half.
|
|
3
|
+
*
|
|
4
|
+
* Registers a single `sidebar.footer.action` slot occupant that renders
|
|
5
|
+
* the QR-code button. The button stays mounted but hidden until the host
|
|
6
|
+
* half's `/_qr/share` returns `ready: true` — that happens exactly when
|
|
7
|
+
* (a) the request passed `connection.requestRejection` (Host/Origin
|
|
8
|
+
* trusted AND browser cookie valid), AND
|
|
9
|
+
* (b) a non-empty launch token was minted.
|
|
10
|
+
* Both conditions jointly mean "the desktop has already completed the
|
|
11
|
+
* token-exchange redirect and now lives on /, not on /?token=…".
|
|
12
|
+
*/
|
|
13
|
+
import { Fragment, createElement, useEffect, useState } from 'react'
|
|
14
|
+
import type { ReactNode } from 'react'
|
|
15
|
+
import { QrButton } from './QrButton.tsx'
|
|
16
|
+
import { fetchShareInfo, type ShareInfo } from './api.ts'
|
|
17
|
+
import { LOCALE_NS, zh, en } from './locales.ts'
|
|
18
|
+
|
|
19
|
+
// Ambient module augmentations — the host runtime supplies the real
|
|
20
|
+
// service shapes via type-only declarations in its `@deepseek-ai/*`
|
|
21
|
+
// packages. We declare just enough for our apply() to type-check.
|
|
22
|
+
declare module '@deepseek-ai/cordis' {
|
|
23
|
+
interface Context {
|
|
24
|
+
locale: {
|
|
25
|
+
register(ns: string, dict: { zh: unknown; en: unknown }): () => void
|
|
26
|
+
bind(ns: string): (k: string) => string
|
|
27
|
+
}
|
|
28
|
+
slots: {
|
|
29
|
+
inject(slot: string, factory: () => unknown): void
|
|
30
|
+
register(opts: {
|
|
31
|
+
name: string
|
|
32
|
+
id: string
|
|
33
|
+
order: number
|
|
34
|
+
locale: string
|
|
35
|
+
}, component: (owner: SidebarFooterActionOwnerPropsLike) => ReactNode): () => void
|
|
36
|
+
}
|
|
37
|
+
/** Empty marker: this entry only needs slots + locale. */
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface SidebarFooterActionOwnerPropsLike {
|
|
42
|
+
wide: boolean
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Services required before this entry activates. */
|
|
46
|
+
export const inject = ['slots', 'locale', 'connection'] as const
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The footer-action component. Mounted by the slot registry; receives the
|
|
50
|
+
* sidebar's wide/rail state via the slot owner props.
|
|
51
|
+
* @param owner - slot owner props from the sidebar shell.
|
|
52
|
+
* @param t - locale reader bound by the apply function below.
|
|
53
|
+
*/
|
|
54
|
+
function FooterAction(
|
|
55
|
+
owner: SidebarFooterActionOwnerPropsLike,
|
|
56
|
+
t: (k: keyof typeof zh) => string,
|
|
57
|
+
): ReactNode {
|
|
58
|
+
const [info, setInfo] = useState<ShareInfo | null>(null)
|
|
59
|
+
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
// Probe on mount; refresh on window focus (a fresh login in another
|
|
62
|
+
// tab + focus-back should immediately enable the button).
|
|
63
|
+
let cancelled = false
|
|
64
|
+
const refresh = (): void => {
|
|
65
|
+
void fetchShareInfo().then((next) => {
|
|
66
|
+
if (!cancelled) setInfo(next)
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
refresh()
|
|
70
|
+
window.addEventListener('focus', refresh)
|
|
71
|
+
return () => {
|
|
72
|
+
cancelled = true
|
|
73
|
+
window.removeEventListener('focus', refresh)
|
|
74
|
+
}
|
|
75
|
+
}, [])
|
|
76
|
+
|
|
77
|
+
// Two-stage gate: null = still probing (no flicker); !ready = server
|
|
78
|
+
// says "no" (button hidden); ready = render.
|
|
79
|
+
if (info === null) return null
|
|
80
|
+
if (!info.ready) return null
|
|
81
|
+
|
|
82
|
+
return createElement(QrButton, { info, wide: owner.wide, t })
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Register dictionaries and the slot occupant.
|
|
87
|
+
* @param ctx - client root context (slots + locale + connection injected).
|
|
88
|
+
*/
|
|
89
|
+
export function apply(ctx: import('@deepseek-ai/cordis').Context): void {
|
|
90
|
+
// Locale registration is a side-effect on the locale service; the slot
|
|
91
|
+
// rendering reads through the bound `t` thunk the apply captures here.
|
|
92
|
+
ctx.effect(() => ctx.locale.register(LOCALE_NS, { zh, en }), 'qr-share: dictionaries')
|
|
93
|
+
const t = ctx.locale.bind(LOCALE_NS) as (k: keyof typeof zh) => string
|
|
94
|
+
|
|
95
|
+
ctx.slots.inject('sidebar.footer.action', () =>
|
|
96
|
+
ctx.slots.register({
|
|
97
|
+
name: 'sidebar.footer.action',
|
|
98
|
+
id: 'qr-share',
|
|
99
|
+
order: 100, // after the Settings icon (which is order 0/10)
|
|
100
|
+
locale: LOCALE_NS,
|
|
101
|
+
}, (owner: SidebarFooterActionOwnerPropsLike) => FooterAction(owner, t)),
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Suppress unused warning for Fragment; we keep the import so future
|
|
106
|
+
// expansions (toast messages, multi-button groups) don't churn imports.
|
|
107
|
+
void Fragment
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-qr-share dictionaries: zh-Hans + en. The shell's locale service
|
|
3
|
+
* drives the active language; the footer action reads via `ctx.locale.bind`.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const LOCALE_NS = 'qr-share' as const
|
|
7
|
+
|
|
8
|
+
export interface QrShareKey {
|
|
9
|
+
'button.label': string
|
|
10
|
+
'button.tooltip': string
|
|
11
|
+
'dialog.title': string
|
|
12
|
+
'dialog.hint': string
|
|
13
|
+
'dialog.urlLabel': string
|
|
14
|
+
'dialog.close': string
|
|
15
|
+
'dialog.error': string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const zh: QrShareKey = {
|
|
19
|
+
'button.label': '扫码登录',
|
|
20
|
+
'button.tooltip': '用手机扫码以同一账号登录',
|
|
21
|
+
'dialog.title': '用手机扫码登录',
|
|
22
|
+
'dialog.hint':
|
|
23
|
+
'用手机相机扫描下方二维码。手机打开后会自动以当前账号登录,token 与浏览器 cookie 同寿命。',
|
|
24
|
+
'dialog.urlLabel': '链接',
|
|
25
|
+
'dialog.close': '关闭',
|
|
26
|
+
'dialog.error': '二维码生成失败',
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const en: QrShareKey = {
|
|
30
|
+
'button.label': 'Mobile Login',
|
|
31
|
+
'button.tooltip': 'Scan with a phone to log in to this session',
|
|
32
|
+
'dialog.title': 'Scan to log in on mobile',
|
|
33
|
+
'dialog.hint':
|
|
34
|
+
'Scan the QR code below with your phone camera. The phone will log in to this session; the token lives as long as the browser cookie.',
|
|
35
|
+
'dialog.urlLabel': 'URL',
|
|
36
|
+
'dialog.close': 'Close',
|
|
37
|
+
'dialog.error': 'Failed to render QR code',
|
|
38
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-qr-share styles. Two pieces:
|
|
3
|
+
*
|
|
4
|
+
* 1. The sidebar footer action button — same row layout as the official
|
|
5
|
+
* Settings trigger (`sidebar.settings` occupant). The shell handles
|
|
6
|
+
* wide vs rail sizing; we only style the inner button + icon.
|
|
7
|
+
*
|
|
8
|
+
* 2. The dialog — a centered overlay with backdrop, mimicking the visual
|
|
9
|
+
* tone of the existing Settings panel without depending on its CSS
|
|
10
|
+
* variables (plugins can't reach the shell's CSS module surface).
|
|
11
|
+
*
|
|
12
|
+
* Colors are kept neutral so the plugin inherits whichever theme the
|
|
13
|
+
* user has active (light/dark) via `color-scheme` + native inheritance.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
.button {
|
|
17
|
+
display: inline-flex;
|
|
18
|
+
align-items: center;
|
|
19
|
+
gap: 8px;
|
|
20
|
+
width: 100%;
|
|
21
|
+
padding: 8px 10px;
|
|
22
|
+
border: 0;
|
|
23
|
+
border-radius: 6px;
|
|
24
|
+
background: transparent;
|
|
25
|
+
color: inherit;
|
|
26
|
+
font: inherit;
|
|
27
|
+
cursor: pointer;
|
|
28
|
+
text-align: left;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.button:hover {
|
|
32
|
+
background: color-mix(in srgb, currentColor 8%, transparent);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.button:focus-visible {
|
|
36
|
+
outline: 2px solid currentColor;
|
|
37
|
+
outline-offset: -2px;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.buttonLabel {
|
|
41
|
+
font-size: 13px;
|
|
42
|
+
white-space: nowrap;
|
|
43
|
+
overflow: hidden;
|
|
44
|
+
text-overflow: ellipsis;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.backdrop {
|
|
48
|
+
position: fixed;
|
|
49
|
+
inset: 0;
|
|
50
|
+
background: color-mix(in srgb, black 45%, transparent);
|
|
51
|
+
display: grid;
|
|
52
|
+
place-items: center;
|
|
53
|
+
z-index: 9999;
|
|
54
|
+
animation: dsh-qr-share-fade-in 120ms ease-out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.dialog {
|
|
58
|
+
background: var(--dsh-qr-share-dialog-bg, #ffffff);
|
|
59
|
+
color: var(--dsh-qr-share-dialog-fg, #111111);
|
|
60
|
+
border-radius: 12px;
|
|
61
|
+
padding: 24px;
|
|
62
|
+
min-width: 320px;
|
|
63
|
+
max-width: 420px;
|
|
64
|
+
box-shadow: 0 12px 32px color-mix(in srgb, black 25%, transparent);
|
|
65
|
+
display: flex;
|
|
66
|
+
flex-direction: column;
|
|
67
|
+
gap: 12px;
|
|
68
|
+
align-items: center;
|
|
69
|
+
animation: dsh-qr-share-pop-in 160ms cubic-bezier(0.2, 0.9, 0.3, 1.2);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
@media (prefers-color-scheme: dark) {
|
|
73
|
+
.dialog {
|
|
74
|
+
background: var(--dsh-qr-share-dialog-bg, #1c1c1f);
|
|
75
|
+
color: var(--dsh-qr-share-dialog-fg, #f0f0f0);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.title {
|
|
80
|
+
font-size: 16px;
|
|
81
|
+
font-weight: 600;
|
|
82
|
+
margin: 0;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.canvas {
|
|
86
|
+
display: block;
|
|
87
|
+
image-rendering: pixelated;
|
|
88
|
+
border-radius: 8px;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.err {
|
|
92
|
+
color: #d23f3f;
|
|
93
|
+
font-size: 13px;
|
|
94
|
+
margin: 0;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.urlBlock {
|
|
98
|
+
display: flex;
|
|
99
|
+
flex-direction: column;
|
|
100
|
+
gap: 4px;
|
|
101
|
+
width: 100%;
|
|
102
|
+
align-items: center;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.urlLabel {
|
|
106
|
+
font-size: 11px;
|
|
107
|
+
text-transform: uppercase;
|
|
108
|
+
letter-spacing: 0.05em;
|
|
109
|
+
opacity: 0.6;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.url {
|
|
113
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
114
|
+
font-size: 11px;
|
|
115
|
+
word-break: break-all;
|
|
116
|
+
background: color-mix(in srgb, currentColor 6%, transparent);
|
|
117
|
+
padding: 6px 8px;
|
|
118
|
+
border-radius: 4px;
|
|
119
|
+
max-width: 100%;
|
|
120
|
+
text-align: center;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.hint {
|
|
124
|
+
font-size: 12px;
|
|
125
|
+
opacity: 0.7;
|
|
126
|
+
text-align: center;
|
|
127
|
+
margin: 0;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
.close {
|
|
131
|
+
margin-top: 4px;
|
|
132
|
+
padding: 8px 16px;
|
|
133
|
+
border: 1px solid color-mix(in srgb, currentColor 20%, transparent);
|
|
134
|
+
background: transparent;
|
|
135
|
+
color: inherit;
|
|
136
|
+
font: inherit;
|
|
137
|
+
border-radius: 6px;
|
|
138
|
+
cursor: pointer;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.close:hover {
|
|
142
|
+
background: color-mix(in srgb, currentColor 8%, transparent);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
.close:focus-visible {
|
|
146
|
+
outline: 2px solid currentColor;
|
|
147
|
+
outline-offset: -2px;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
@keyframes dsh-qr-share-fade-in {
|
|
151
|
+
from { opacity: 0; }
|
|
152
|
+
to { opacity: 1; }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
@keyframes dsh-qr-share-pop-in {
|
|
156
|
+
from { opacity: 0; transform: scale(0.92); }
|
|
157
|
+
to { opacity: 1; transform: scale(1); }
|
|
158
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-qr-share Config schema. The host half is mounted as a cordis
|
|
3
|
+
* `insert` row; the `config` field in the patch is fed here. Defaults
|
|
4
|
+
* match a "just works" deployment with no patch config.
|
|
5
|
+
*
|
|
6
|
+
* Note: we DO NOT read DSH_PUBLIC_HOST here — the QR URL is composed
|
|
7
|
+
* by the browser from `window.location.origin`, so no configuration is
|
|
8
|
+
* required for the QR to point at the right host. The web-app
|
|
9
|
+
* trustedHosts patch (set elsewhere) still gates the /_qr/share route
|
|
10
|
+
* via the same trust fence that gates /api.
|
|
11
|
+
*
|
|
12
|
+
* We intentionally avoid @deepseek-ai/schemastery / zod — the patch
|
|
13
|
+
* overlay can only feed plain JS values, so a hand-rolled validator
|
|
14
|
+
* keeps the host bundle dependency-free.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Wire shape (defaults applied). */
|
|
18
|
+
export interface ResolvedConfig {
|
|
19
|
+
/** Master switch: false short-circuits the route to 404. */
|
|
20
|
+
enabled: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const DEFAULTS: ResolvedConfig = { enabled: true }
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Apply defaults + permissive coercion. Patch-time `config:` maps are
|
|
27
|
+
* untyped JS objects; anything truthy that isn't explicitly `false`
|
|
28
|
+
* counts as enabled.
|
|
29
|
+
* @param raw - patch-time config object (or undefined).
|
|
30
|
+
* @returns resolved config.
|
|
31
|
+
*/
|
|
32
|
+
export function resolveConfig(raw: unknown): ResolvedConfig {
|
|
33
|
+
if (raw === null || typeof raw !== 'object') return DEFAULTS
|
|
34
|
+
const value = (raw as { enabled?: unknown }).enabled
|
|
35
|
+
if (value === false) return { enabled: false }
|
|
36
|
+
return DEFAULTS
|
|
37
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-qr-share host context types. Declares the cordis Context services
|
|
3
|
+
* the host half consumes as ambient module augmentations. The actual
|
|
4
|
+
* services come from the loaded Connection + WebServer plugins; we
|
|
5
|
+
* declare the slices we use and rely on the module loader to supply
|
|
6
|
+
* the real implementations.
|
|
7
|
+
*
|
|
8
|
+
* Runtime: zero import from monorepo; type-only augmentations are
|
|
9
|
+
* stripped by tsdown.
|
|
10
|
+
*/
|
|
11
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
12
|
+
|
|
13
|
+
/** Minimal slice of `connection.requestRejection` we consume. */
|
|
14
|
+
export interface ConnectionRejectionLike {
|
|
15
|
+
/** 401 when cookie missing/expired; 403 when Host/Origin not trusted. */
|
|
16
|
+
requestRejection(req: IncomingMessage): number | undefined
|
|
17
|
+
/** Add the fresh process token to a clean URL. */
|
|
18
|
+
authenticatedUrl(baseUrl: string): string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Minimal slice of `webServer.register` we consume. */
|
|
22
|
+
export interface WebServerLike {
|
|
23
|
+
register(route: {
|
|
24
|
+
kind: 'exact'
|
|
25
|
+
path: string
|
|
26
|
+
handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
|
|
27
|
+
}): () => void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
declare module '@deepseek-ai/cordis' {
|
|
31
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
|
32
|
+
interface Context {
|
|
33
|
+
connection: ConnectionRejectionLike
|
|
34
|
+
webServer: WebServerLike
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Re-export for ergonomic imports. */
|
|
39
|
+
export type Context = import('@deepseek-ai/cordis').Context
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-qr-share host half.
|
|
3
|
+
*
|
|
4
|
+
* Exposes `/_qr/share` (exact HTTP route) to authenticated browsers. The
|
|
5
|
+
* response carries the fresh process launch token; the browser then
|
|
6
|
+
* composes the final URL as `${window.location.origin}/?token=${token}`
|
|
7
|
+
* so the QR code automatically follows whatever authority the desktop
|
|
8
|
+
* actually used (LAN IP, public domain, reverse-proxied sub-path on a
|
|
9
|
+
* port-80 host, etc.) — no DSH_PUBLIC_HOST coupling.
|
|
10
|
+
*
|
|
11
|
+
* Security: every request passes through `connection.requestRejection`,
|
|
12
|
+
* which applies the same trust fence as the /api gateway:
|
|
13
|
+
* - 403 if Host is not in `trustedHosts` (LAN literals + --trusted-host)
|
|
14
|
+
* - 401 if the browser cookie is missing/expired (i.e. not yet logged in)
|
|
15
|
+
* The token is only returned when both checks pass; the same code path
|
|
16
|
+
* also drives the client-side `ready` flag so the button is hidden until
|
|
17
|
+
* the user is actually authenticated.
|
|
18
|
+
*/
|
|
19
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
20
|
+
import { assertNodeContext } from './invariant.ts'
|
|
21
|
+
import { resolveConfig, type ResolvedConfig } from './config.ts'
|
|
22
|
+
import type { Context } from './context-types.ts'
|
|
23
|
+
|
|
24
|
+
export const name = 'dsh-qr-share'
|
|
25
|
+
export { resolveConfig }
|
|
26
|
+
export type { ResolvedConfig } from './config.ts'
|
|
27
|
+
|
|
28
|
+
/** Required services from the cordis fiber. */
|
|
29
|
+
export const inject = ['connection', 'webServer'] as const
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The exact HTTP route name. Hard-coded so the plugin self-documents and
|
|
33
|
+
* a misconfigured patch never silently mounts under a wrong path.
|
|
34
|
+
*/
|
|
35
|
+
const ROUTE_PATH = '/_qr/share'
|
|
36
|
+
|
|
37
|
+
/** Wall-clock approximate TTL of the browser cookie — purely advisory. */
|
|
38
|
+
const ADVISORY_TOKEN_TTL_MS = 24 * 60 * 60 * 1000
|
|
39
|
+
|
|
40
|
+
/** Resolve the launch token from the live BrowserAuth, given a request. */
|
|
41
|
+
function resolveToken(ctx: Context, hostHeader: string): string {
|
|
42
|
+
const baseUrl = `http://${hostHeader}`
|
|
43
|
+
const launchUrl = ctx.connection.authenticatedUrl(baseUrl)
|
|
44
|
+
const token = new URL(launchUrl).searchParams.get('token')
|
|
45
|
+
return token ?? ''
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Mount the route and return the disposer. Plugin fiber disposal removes
|
|
50
|
+
* it via the webServer's own bookkeeping.
|
|
51
|
+
* @param ctx - host cordis context (connection + webServer injected).
|
|
52
|
+
* @param rawConfig - patch-time config (validated by resolveConfig).
|
|
53
|
+
*/
|
|
54
|
+
export function apply(ctx: Context, rawConfig: unknown): void {
|
|
55
|
+
assertNodeContext()
|
|
56
|
+
const config = resolveConfig(rawConfig)
|
|
57
|
+
if (!config.enabled) return
|
|
58
|
+
|
|
59
|
+
const connection = ctx.connection
|
|
60
|
+
const webServer = ctx.webServer
|
|
61
|
+
|
|
62
|
+
webServer.register({
|
|
63
|
+
kind: 'exact',
|
|
64
|
+
path: ROUTE_PATH,
|
|
65
|
+
handler: (req: IncomingMessage, res: ServerResponse): void => {
|
|
66
|
+
// Trust + auth gate. Returns the rejection status (401/403) or undefined.
|
|
67
|
+
const rejection = connection.requestRejection(req)
|
|
68
|
+
if (rejection !== undefined) {
|
|
69
|
+
// Match /api's minimal response shape: status + plain text body.
|
|
70
|
+
// No JSON leak about which check tripped.
|
|
71
|
+
res.writeHead(rejection, {
|
|
72
|
+
'content-type': 'text/plain; charset=utf-8',
|
|
73
|
+
'cache-control': 'no-store',
|
|
74
|
+
})
|
|
75
|
+
res.end(
|
|
76
|
+
rejection === 401
|
|
77
|
+
? 'qr-share: authentication required\n'
|
|
78
|
+
: 'qr-share: forbidden\n',
|
|
79
|
+
)
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Use req.headers.host (the authority the browser actually used).
|
|
84
|
+
// Trust fence has just approved it, so we don't risk SSRF here.
|
|
85
|
+
const hostHeader = req.headers.host
|
|
86
|
+
if (typeof hostHeader !== 'string' || hostHeader === '') {
|
|
87
|
+
res.writeHead(400, { 'content-type': 'text/plain; charset=utf-8' })
|
|
88
|
+
res.end('qr-share: missing Host header\n')
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const token = resolveToken(ctx, hostHeader)
|
|
93
|
+
const ready = token !== ''
|
|
94
|
+
|
|
95
|
+
const body = JSON.stringify({
|
|
96
|
+
ready,
|
|
97
|
+
token,
|
|
98
|
+
tokenTtlMs: ADVISORY_TOKEN_TTL_MS,
|
|
99
|
+
})
|
|
100
|
+
res.writeHead(200, {
|
|
101
|
+
'content-type': 'application/json; charset=utf-8',
|
|
102
|
+
'cache-control': 'no-store',
|
|
103
|
+
})
|
|
104
|
+
res.end(body)
|
|
105
|
+
},
|
|
106
|
+
})
|
|
107
|
+
}
|
package/src/invariant.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-qr-share invariant — fails fast when the host half is loaded without
|
|
3
|
+
* the services it declares in `inject`. Mirrors the official DSH plugin
|
|
4
|
+
* invariant pattern (used by every bundle in the monorepo) so a misconfigured
|
|
5
|
+
* mount surfaces a precise error rather than a runtime undefined access.
|
|
6
|
+
*/
|
|
7
|
+
import { createRequire } from 'node:module'
|
|
8
|
+
|
|
9
|
+
const require = createRequire(import.meta.url)
|
|
10
|
+
|
|
11
|
+
/** Throws when the import-time guard fails; never returns. */
|
|
12
|
+
export function assert(condition: unknown, message: string): asserts condition {
|
|
13
|
+
if (!condition) {
|
|
14
|
+
throw new Error(`dsh-qr-share: ${message}`)
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Guard `require` to the official Node context — the host half MUST NOT
|
|
20
|
+
* be loaded through the browser module table. The bundler's purity gate
|
|
21
|
+
* already blocks Node-builtin imports in the client bundles; this is the
|
|
22
|
+
* defense-in-depth runtime check.
|
|
23
|
+
*/
|
|
24
|
+
export function assertNodeContext(): void {
|
|
25
|
+
// Trigger a Node-only import to fail-fast in any non-Node runtime.
|
|
26
|
+
require('node:fs')
|
|
27
|
+
}
|