@noy-db/in-liff 0.4.0-pre.2

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 vLannaAi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,68 @@
1
+ # @noy-db/in-liff
2
+
3
+ [![npm](https://img.shields.io/npm/v/%40noy-db/in-liff.svg)](https://www.npmjs.com/package/@noy-db/in-liff)
4
+
5
+ > LIFF (LINE Front-end Framework) shell binding for noy-db
6
+
7
+ Part of [**`@noy-db/hub`**](https://www.npmjs.com/package/@noy-db/hub) — the zero-knowledge, offline-first, encrypted document store.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pnpm add @noy-db/hub @noy-db/in-liff
13
+ ```
14
+
15
+ ## What it is
16
+
17
+ The boot layer for a noy-db portal SPA opened from LINE: one `initLiffApp()` call establishes the **shell** (`'liff' | 'browser' | 'pwa'` — one SPA, three shells), enforces LINE Login, reads the ID token for the `@noy-db/on-oidc` split-key unlock, and ingests a `@noy-db/hub/share-link` deep link from the current location.
18
+
19
+ The LIFF SDK is **injected, never a dependency** — every entry point takes a `LiffLike` (the six-member structural slice this package calls), so apps pass the real `liff` global and tests pass fakes.
20
+
21
+ ```ts
22
+ import liff from '@line/liff'
23
+ import { initLiffApp, getFreshIdToken, openExternal } from '@noy-db/in-liff'
24
+
25
+ const ctx = await initLiffApp({ liff, liffId: import.meta.env.VITE_LIFF_ID })
26
+ // ctx: { shell, loggedIn, idToken, inClient, deepLink }
27
+
28
+ // Later, right before an on-oidc serverHalf fetch:
29
+ const token = getFreshIdToken(liff) // re-login on expiry (navigates away), or { onExpired: 'throw' }
30
+ ```
31
+
32
+ ## Token lifecycle
33
+
34
+ LIFF ID tokens live ~1 hour with **no silent refresh**. Expiry is detected client-side from the JWT `exp` (no network, no signature check — verification is the key-connector server's job, per `@noy-db/on-oidc`'s documented split). An unlocked noy-db session **survives token expiry**: the token is needed again only at the next serverHalf fetch — call `getFreshIdToken` exactly there.
35
+
36
+ ## The platform handoff matrix (escape hatch)
37
+
38
+ `openExternal(liff, url)` breaks out of LINE's in-app WebView:
39
+
40
+ | Platform | What happens |
41
+ |---|---|
42
+ | **Android** | In-scope links open the **installed PWA directly** (WebAPK link capture); the WebAPK shares Chrome's origin storage. |
43
+ | **iOS** | Always opens Safari; the installed home-screen app cannot be targeted by URL and has its **own storage partition** — show an interstitial for the manual hop. |
44
+ | **All** | LINE's WebView storage is always isolated: the new shell starts empty and re-enrolls via the custodian re-invite (`@noy-db/on-oidc`) — a ceremony, not a data transfer. |
45
+
46
+ ## In-LINE constraints
47
+
48
+ - **No WebAuthn** in LINE's in-app WebView — the in-LINE offline lock options are PIN and device-trust (`@noy-db/on-pin`); biometrics become available after detaching to the external browser / PWA shells.
49
+ - IndexedDB and `crypto.subtle` are available in all three shells (LIFF apps are HTTPS by requirement).
50
+
51
+ ## Scope
52
+
53
+ Login + `openWindow` only — no LIFF messaging/share APIs, no server code, no UI. The PWA-side helpers (persistence, eviction guard, install prompts) live in `@noy-db/in-pwa`; the two packages share the `AppShellContext` union structurally, with no dependency between them.
54
+
55
+ ## Status
56
+
57
+ **Pre-release** (`0.4.0-pre.x`). API may change before `1.0`.
58
+
59
+ ## Documentation
60
+
61
+ See the [main repository](https://github.com/vLannaAi/noy-db#readme) for setup, examples, and the full subsystem catalog.
62
+
63
+ - Source — [`packages/in-liff`](https://github.com/vLannaAi/noy-db/tree/main/packages/in-liff)
64
+ - Issues — [github.com/vLannaAi/noy-db/issues](https://github.com/vLannaAi/noy-db/issues)
65
+
66
+ ## License
67
+
68
+ [MIT](./LICENSE) © vLannaAi
@@ -0,0 +1,153 @@
1
+ import { ShareLink } from '@noy-db/hub/share-link';
2
+ export { ShareLink } from '@noy-db/hub/share-link';
3
+
4
+ /**
5
+ * **@noy-db/in-liff** — LIFF (LINE Front-end Framework) shell binding for
6
+ * noy-db: boot + shell-context detection across the three shells one
7
+ * portal SPA runs in (inside LINE, external browser, installed PWA),
8
+ * LINE ID-token lifecycle with re-login on expiry, share-link deep-link
9
+ * ingestion, and the external-browser escape hatch.
10
+ *
11
+ * The LIFF SDK is **injected, never depended on**: every entry point
12
+ * takes a {@link LiffLike} — the structural slice of the `liff` global
13
+ * this package actually calls — so apps pass the real `liff` object and
14
+ * tests pass fakes. CI runs fully mocked, per family convention.
15
+ *
16
+ * Platform truths this package encodes (the portal handoff matrix):
17
+ * - **Android**: an in-scope link opened via {@link openExternal} lands
18
+ * in the installed PWA directly (WebAPK link capture); the WebAPK
19
+ * shares Chrome's origin storage.
20
+ * - **iOS**: links always open Safari; an installed home-screen app
21
+ * cannot be targeted by URL and has its own storage partition — the
22
+ * handoff is a manual hop behind an interstitial.
23
+ * - **LINE's in-app WebView storage is always isolated** — moving to
24
+ * another shell is a re-enroll + re-sync ceremony (see
25
+ * `@noy-db/on-oidc`'s firm re-invite flow), never a data transfer.
26
+ * - **No WebAuthn inside LINE's WebView** — the in-LINE lock options are
27
+ * PIN and device-trust (`@noy-db/on-pin`); biometric unlocks become
28
+ * available only in the external-browser/PWA shells. IndexedDB and
29
+ * `crypto.subtle` are available in all three shells.
30
+ *
31
+ * @packageDocumentation
32
+ */
33
+
34
+ /**
35
+ * Which shell the SPA is running in. Mirrors the union exported by
36
+ * `@noy-db/in-pwa` — kept as a structural redeclaration (identical
37
+ * string union) rather than an import so neither satellite depends on
38
+ * the other; satellites peer-depend only on the hub, per family law.
39
+ */
40
+ type AppShellContext = 'liff' | 'browser' | 'pwa';
41
+ /**
42
+ * The structural slice of the LIFF SDK this package calls. Pass the
43
+ * real `liff` global in apps; pass a fake in tests. Only these six
44
+ * members are ever touched — messaging/share APIs are out of scope.
45
+ */
46
+ interface LiffLike {
47
+ init(config: {
48
+ liffId: string;
49
+ }): Promise<void>;
50
+ isLoggedIn(): boolean;
51
+ /** Navigates away to LINE Login in a real LIFF runtime. */
52
+ login(options?: {
53
+ redirectUri?: string;
54
+ }): void;
55
+ getIDToken(): string | null;
56
+ isInClient(): boolean;
57
+ openWindow(params: {
58
+ url: string;
59
+ external?: boolean;
60
+ }): void;
61
+ }
62
+ /** Thrown when `liff.init` fails — the shell cannot be established. */
63
+ declare class LiffInitError extends Error {
64
+ readonly code = "LIFF_INIT_FAILED";
65
+ constructor(message: string, options?: {
66
+ cause?: unknown;
67
+ });
68
+ }
69
+ /**
70
+ * Thrown for ID-token problems this package can detect client-side:
71
+ * a structurally malformed JWT, or expiry when the caller opted into
72
+ * `onExpired: 'throw'`. Signature verification is NEVER done here —
73
+ * that is the key-connector server's job (see `@noy-db/on-oidc`).
74
+ */
75
+ declare class LiffTokenError extends Error {
76
+ readonly code: 'LIFF_TOKEN_EXPIRED' | 'LIFF_TOKEN_MALFORMED';
77
+ constructor(code: 'LIFF_TOKEN_EXPIRED' | 'LIFF_TOKEN_MALFORMED', message: string);
78
+ }
79
+ /** Everything {@link initLiffApp} establishes about the running shell. */
80
+ interface LiffAppContext {
81
+ /** 'liff' inside LINE; otherwise the pwa/browser split. */
82
+ readonly shell: AppShellContext;
83
+ readonly loggedIn: boolean;
84
+ /** LINE ID token when logged in and the SDK yields one; else null. */
85
+ readonly idToken: string | null;
86
+ /** `liff.isInClient()` — true only inside LINE's in-app WebView. */
87
+ readonly inClient: boolean;
88
+ /**
89
+ * The share link the app was opened with, or null when the current
90
+ * location is not a share link. Only `ShareLinkParseError` is
91
+ * swallowed into null — anything else propagates.
92
+ */
93
+ readonly deepLink: ShareLink | null;
94
+ }
95
+ interface InitLiffAppOptions {
96
+ liff: LiffLike;
97
+ liffId: string;
98
+ /**
99
+ * Require a logged-in LINE session, calling `liff.login()` when
100
+ * absent. Default true. NOTE: in a real LIFF runtime `login()`
101
+ * NAVIGATES AWAY — code after it only runs in test fakes or when the
102
+ * user is already logged in.
103
+ */
104
+ requireLogin?: boolean;
105
+ /** Current location href override (tests); default `location.href`. */
106
+ locationHref?: string;
107
+ }
108
+ /**
109
+ * Boot the LIFF shell: `liff.init`, optional login enforcement, shell
110
+ * detection, ID-token read, and deep-link ingestion — one call at app
111
+ * start, returning everything the portal needs to route.
112
+ */
113
+ declare function initLiffApp(options: InitLiffAppOptions): Promise<LiffAppContext>;
114
+ interface GetFreshIdTokenOptions {
115
+ /**
116
+ * What to do when the token is missing or expired: `'login'`
117
+ * (default) triggers `liff.login()` re-auth and returns null —
118
+ * remember login navigates away in a real LIFF runtime; `'throw'`
119
+ * raises {@link LiffTokenError} for the app to handle.
120
+ */
121
+ onExpired?: 'login' | 'throw';
122
+ /** Clock-skew allowance in seconds. Default 30. */
123
+ skewSeconds?: number;
124
+ }
125
+ /**
126
+ * Return a currently-valid LINE ID token, or handle expiry.
127
+ *
128
+ * LIFF ID tokens live ~1 hour with **no silent refresh** — expiry is
129
+ * detected client-side from the JWT `exp` claim (a 20-line local
130
+ * decode; no network, no signature check — mirrors `@noy-db/on-oidc`'s
131
+ * documented client/server split). An unlocked noy-db session SURVIVES
132
+ * token expiry: the token is only needed again at the next
133
+ * serverHalf fetch (see on-oidc), which is exactly when to call this.
134
+ */
135
+ declare function getFreshIdToken(liff: LiffLike, options?: GetFreshIdTokenOptions): string | null;
136
+ /**
137
+ * Escape hatch out of LINE's in-app WebView into the default browser
138
+ * (`liff.openWindow` with `external: true`).
139
+ *
140
+ * What actually happens per platform — the portal handoff matrix:
141
+ * - **Android**: if the installed PWA's scope covers `url`, the link
142
+ * opens the installed app directly (WebAPK link capture), which
143
+ * shares Chrome's origin storage.
144
+ * - **iOS**: always opens Safari; the installed home-screen app cannot
145
+ * be targeted and has a separate storage partition — show an
146
+ * interstitial for the manual hop.
147
+ * - In every case LINE's WebView storage stays behind: the new shell
148
+ * starts empty and re-enrolls (custodian re-invite; see
149
+ * `@noy-db/on-oidc`) — a ceremony, not a data transfer.
150
+ */
151
+ declare function openExternal(liff: LiffLike, url: string): void;
152
+
153
+ export { type AppShellContext, type GetFreshIdTokenOptions, type InitLiffAppOptions, type LiffAppContext, LiffInitError, type LiffLike, LiffTokenError, getFreshIdToken, initLiffApp, openExternal };
package/dist/index.js ADDED
@@ -0,0 +1,101 @@
1
+ // src/index.ts
2
+ import { parseShareLink, ShareLinkParseError } from "@noy-db/hub/share-link";
3
+ var LiffInitError = class extends Error {
4
+ code = "LIFF_INIT_FAILED";
5
+ constructor(message, options) {
6
+ super(message, options);
7
+ this.name = "LiffInitError";
8
+ }
9
+ };
10
+ var LiffTokenError = class extends Error {
11
+ code;
12
+ constructor(code, message) {
13
+ super(message);
14
+ this.name = "LiffTokenError";
15
+ this.code = code;
16
+ }
17
+ };
18
+ async function initLiffApp(options) {
19
+ const { liff, liffId, requireLogin = true } = options;
20
+ try {
21
+ await liff.init({ liffId });
22
+ } catch (cause) {
23
+ throw new LiffInitError(
24
+ `initLiffApp: liff.init failed for liffId "${liffId}". Check the LIFF app configuration (endpoint URL, channel).`,
25
+ { cause }
26
+ );
27
+ }
28
+ let loggedIn = liff.isLoggedIn();
29
+ if (requireLogin && !loggedIn) {
30
+ liff.login();
31
+ loggedIn = liff.isLoggedIn();
32
+ }
33
+ const inClient = liff.isInClient();
34
+ const shell = inClient ? "liff" : detectStandalone() ? "pwa" : "browser";
35
+ const idToken = loggedIn ? liff.getIDToken() : null;
36
+ const href = options.locationHref ?? (typeof location !== "undefined" ? location.href : void 0);
37
+ let deepLink = null;
38
+ if (href !== void 0) {
39
+ try {
40
+ deepLink = parseShareLink(href);
41
+ } catch (err) {
42
+ if (!(err instanceof ShareLinkParseError)) throw err;
43
+ deepLink = null;
44
+ }
45
+ }
46
+ return { shell, loggedIn, idToken, inClient, deepLink };
47
+ }
48
+ function detectStandalone() {
49
+ try {
50
+ if (typeof matchMedia === "function" && matchMedia("(display-mode: standalone)").matches) {
51
+ return true;
52
+ }
53
+ } catch {
54
+ }
55
+ const nav = typeof navigator !== "undefined" ? navigator : void 0;
56
+ return nav?.standalone === true;
57
+ }
58
+ function getFreshIdToken(liff, options = {}) {
59
+ const { onExpired = "login", skewSeconds = 30 } = options;
60
+ const token = liff.isLoggedIn() ? liff.getIDToken() : null;
61
+ if (token !== null) {
62
+ const exp = decodeJwtExp(token);
63
+ if (exp === null) {
64
+ throw new LiffTokenError(
65
+ "LIFF_TOKEN_MALFORMED",
66
+ "getFreshIdToken: the ID token is not a decodable JWT (missing or unreadable exp claim)."
67
+ );
68
+ }
69
+ if (exp * 1e3 > Date.now() + skewSeconds * 1e3) return token;
70
+ }
71
+ if (onExpired === "throw") {
72
+ throw new LiffTokenError(
73
+ "LIFF_TOKEN_EXPIRED",
74
+ "getFreshIdToken: the LINE ID token is missing or expired. Re-authenticate via liff.login()."
75
+ );
76
+ }
77
+ liff.login();
78
+ return null;
79
+ }
80
+ function decodeJwtExp(token) {
81
+ const parts = token.split(".");
82
+ if (parts.length !== 3) return null;
83
+ try {
84
+ const payload = parts[1].replace(/-/g, "+").replace(/_/g, "/");
85
+ const json = JSON.parse(atob(payload));
86
+ return typeof json.exp === "number" && Number.isFinite(json.exp) ? json.exp : null;
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
91
+ function openExternal(liff, url) {
92
+ liff.openWindow({ url, external: true });
93
+ }
94
+ export {
95
+ LiffInitError,
96
+ LiffTokenError,
97
+ getFreshIdToken,
98
+ initLiffApp,
99
+ openExternal
100
+ };
101
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * **@noy-db/in-liff** — LIFF (LINE Front-end Framework) shell binding for\n * noy-db: boot + shell-context detection across the three shells one\n * portal SPA runs in (inside LINE, external browser, installed PWA),\n * LINE ID-token lifecycle with re-login on expiry, share-link deep-link\n * ingestion, and the external-browser escape hatch.\n *\n * The LIFF SDK is **injected, never depended on**: every entry point\n * takes a {@link LiffLike} — the structural slice of the `liff` global\n * this package actually calls — so apps pass the real `liff` object and\n * tests pass fakes. CI runs fully mocked, per family convention.\n *\n * Platform truths this package encodes (the portal handoff matrix):\n * - **Android**: an in-scope link opened via {@link openExternal} lands\n * in the installed PWA directly (WebAPK link capture); the WebAPK\n * shares Chrome's origin storage.\n * - **iOS**: links always open Safari; an installed home-screen app\n * cannot be targeted by URL and has its own storage partition — the\n * handoff is a manual hop behind an interstitial.\n * - **LINE's in-app WebView storage is always isolated** — moving to\n * another shell is a re-enroll + re-sync ceremony (see\n * `@noy-db/on-oidc`'s firm re-invite flow), never a data transfer.\n * - **No WebAuthn inside LINE's WebView** — the in-LINE lock options are\n * PIN and device-trust (`@noy-db/on-pin`); biometric unlocks become\n * available only in the external-browser/PWA shells. IndexedDB and\n * `crypto.subtle` are available in all three shells.\n *\n * @packageDocumentation\n */\n\nimport { parseShareLink, ShareLinkParseError } from '@noy-db/hub/share-link'\nimport type { ShareLink } from '@noy-db/hub/share-link'\n\n/**\n * Which shell the SPA is running in. Mirrors the union exported by\n * `@noy-db/in-pwa` — kept as a structural redeclaration (identical\n * string union) rather than an import so neither satellite depends on\n * the other; satellites peer-depend only on the hub, per family law.\n */\nexport type AppShellContext = 'liff' | 'browser' | 'pwa'\n\n/**\n * The structural slice of the LIFF SDK this package calls. Pass the\n * real `liff` global in apps; pass a fake in tests. Only these six\n * members are ever touched — messaging/share APIs are out of scope.\n */\nexport interface LiffLike {\n init(config: { liffId: string }): Promise<void>\n isLoggedIn(): boolean\n /** Navigates away to LINE Login in a real LIFF runtime. */\n login(options?: { redirectUri?: string }): void\n getIDToken(): string | null\n isInClient(): boolean\n openWindow(params: { url: string; external?: boolean }): void\n}\n\n/** Thrown when `liff.init` fails — the shell cannot be established. */\nexport class LiffInitError extends Error {\n readonly code = 'LIFF_INIT_FAILED'\n constructor(message: string, options?: { cause?: unknown }) {\n super(message, options)\n this.name = 'LiffInitError'\n }\n}\n\n/**\n * Thrown for ID-token problems this package can detect client-side:\n * a structurally malformed JWT, or expiry when the caller opted into\n * `onExpired: 'throw'`. Signature verification is NEVER done here —\n * that is the key-connector server's job (see `@noy-db/on-oidc`).\n */\nexport class LiffTokenError extends Error {\n readonly code: 'LIFF_TOKEN_EXPIRED' | 'LIFF_TOKEN_MALFORMED'\n constructor(code: 'LIFF_TOKEN_EXPIRED' | 'LIFF_TOKEN_MALFORMED', message: string) {\n super(message)\n this.name = 'LiffTokenError'\n this.code = code\n }\n}\n\n/** Everything {@link initLiffApp} establishes about the running shell. */\nexport interface LiffAppContext {\n /** 'liff' inside LINE; otherwise the pwa/browser split. */\n readonly shell: AppShellContext\n readonly loggedIn: boolean\n /** LINE ID token when logged in and the SDK yields one; else null. */\n readonly idToken: string | null\n /** `liff.isInClient()` — true only inside LINE's in-app WebView. */\n readonly inClient: boolean\n /**\n * The share link the app was opened with, or null when the current\n * location is not a share link. Only `ShareLinkParseError` is\n * swallowed into null — anything else propagates.\n */\n readonly deepLink: ShareLink | null\n}\n\nexport interface InitLiffAppOptions {\n liff: LiffLike\n liffId: string\n /**\n * Require a logged-in LINE session, calling `liff.login()` when\n * absent. Default true. NOTE: in a real LIFF runtime `login()`\n * NAVIGATES AWAY — code after it only runs in test fakes or when the\n * user is already logged in.\n */\n requireLogin?: boolean\n /** Current location href override (tests); default `location.href`. */\n locationHref?: string\n}\n\n/**\n * Boot the LIFF shell: `liff.init`, optional login enforcement, shell\n * detection, ID-token read, and deep-link ingestion — one call at app\n * start, returning everything the portal needs to route.\n */\nexport async function initLiffApp(options: InitLiffAppOptions): Promise<LiffAppContext> {\n const { liff, liffId, requireLogin = true } = options\n try {\n await liff.init({ liffId })\n } catch (cause) {\n throw new LiffInitError(\n `initLiffApp: liff.init failed for liffId \"${liffId}\". ` +\n `Check the LIFF app configuration (endpoint URL, channel).`,\n { cause },\n )\n }\n\n let loggedIn = liff.isLoggedIn()\n if (requireLogin && !loggedIn) {\n liff.login()\n // Real LIFF navigated away above; a fake may have flipped state.\n loggedIn = liff.isLoggedIn()\n }\n\n const inClient = liff.isInClient()\n const shell: AppShellContext = inClient ? 'liff' : detectStandalone() ? 'pwa' : 'browser'\n const idToken = loggedIn ? liff.getIDToken() : null\n\n const href = options.locationHref ?? (typeof location !== 'undefined' ? location.href : undefined)\n let deepLink: ShareLink | null = null\n if (href !== undefined) {\n try {\n deepLink = parseShareLink(href)\n } catch (err) {\n if (!(err instanceof ShareLinkParseError)) throw err\n deepLink = null\n }\n }\n\n return { shell, loggedIn, idToken, inClient, deepLink }\n}\n\n/**\n * The pwa/browser split OUTSIDE LINE. Same rule as\n * `@noy-db/in-pwa`'s `getDisplayContext` (kept in sync by comment, not\n * import): standalone display-mode media query, plus iOS Safari's\n * legacy `navigator.standalone`.\n */\nfunction detectStandalone(): boolean {\n try {\n if (typeof matchMedia === 'function' && matchMedia('(display-mode: standalone)').matches) {\n return true\n }\n } catch {\n // matchMedia hostile/absent — fall through to the iOS flag.\n }\n const nav = typeof navigator !== 'undefined'\n ? (navigator as Navigator & { standalone?: boolean })\n : undefined\n return nav?.standalone === true\n}\n\nexport interface GetFreshIdTokenOptions {\n /**\n * What to do when the token is missing or expired: `'login'`\n * (default) triggers `liff.login()` re-auth and returns null —\n * remember login navigates away in a real LIFF runtime; `'throw'`\n * raises {@link LiffTokenError} for the app to handle.\n */\n onExpired?: 'login' | 'throw'\n /** Clock-skew allowance in seconds. Default 30. */\n skewSeconds?: number\n}\n\n/**\n * Return a currently-valid LINE ID token, or handle expiry.\n *\n * LIFF ID tokens live ~1 hour with **no silent refresh** — expiry is\n * detected client-side from the JWT `exp` claim (a 20-line local\n * decode; no network, no signature check — mirrors `@noy-db/on-oidc`'s\n * documented client/server split). An unlocked noy-db session SURVIVES\n * token expiry: the token is only needed again at the next\n * serverHalf fetch (see on-oidc), which is exactly when to call this.\n */\nexport function getFreshIdToken(\n liff: LiffLike,\n options: GetFreshIdTokenOptions = {},\n): string | null {\n const { onExpired = 'login', skewSeconds = 30 } = options\n const token = liff.isLoggedIn() ? liff.getIDToken() : null\n\n if (token !== null) {\n const exp = decodeJwtExp(token)\n if (exp === null) {\n throw new LiffTokenError(\n 'LIFF_TOKEN_MALFORMED',\n 'getFreshIdToken: the ID token is not a decodable JWT (missing or unreadable exp claim).',\n )\n }\n if (exp * 1000 > Date.now() + skewSeconds * 1000) return token\n }\n\n if (onExpired === 'throw') {\n throw new LiffTokenError(\n 'LIFF_TOKEN_EXPIRED',\n 'getFreshIdToken: the LINE ID token is missing or expired. Re-authenticate via liff.login().',\n )\n }\n liff.login()\n return null\n}\n\n/** Decode the `exp` claim (seconds) from a JWT, or null if malformed. */\nfunction decodeJwtExp(token: string): number | null {\n const parts = token.split('.')\n if (parts.length !== 3) return null\n try {\n const payload = parts[1]!.replace(/-/g, '+').replace(/_/g, '/')\n const json = JSON.parse(atob(payload)) as { exp?: unknown }\n return typeof json.exp === 'number' && Number.isFinite(json.exp) ? json.exp : null\n } catch {\n return null\n }\n}\n\n/**\n * Escape hatch out of LINE's in-app WebView into the default browser\n * (`liff.openWindow` with `external: true`).\n *\n * What actually happens per platform — the portal handoff matrix:\n * - **Android**: if the installed PWA's scope covers `url`, the link\n * opens the installed app directly (WebAPK link capture), which\n * shares Chrome's origin storage.\n * - **iOS**: always opens Safari; the installed home-screen app cannot\n * be targeted and has a separate storage partition — show an\n * interstitial for the manual hop.\n * - In every case LINE's WebView storage stays behind: the new shell\n * starts empty and re-enrolls (custodian re-invite; see\n * `@noy-db/on-oidc`) — a ceremony, not a data transfer.\n */\nexport function openExternal(liff: LiffLike, url: string): void {\n liff.openWindow({ url, external: true })\n}\n\nexport type { ShareLink } from '@noy-db/hub/share-link'\n"],"mappings":";AA8BA,SAAS,gBAAgB,2BAA2B;AA2B7C,IAAM,gBAAN,cAA4B,MAAM;AAAA,EAC9B,OAAO;AAAA,EAChB,YAAY,SAAiB,SAA+B;AAC1D,UAAM,SAAS,OAAO;AACtB,SAAK,OAAO;AAAA,EACd;AACF;AAQO,IAAM,iBAAN,cAA6B,MAAM;AAAA,EAC/B;AAAA,EACT,YAAY,MAAqD,SAAiB;AAChF,UAAM,OAAO;AACb,SAAK,OAAO;AACZ,SAAK,OAAO;AAAA,EACd;AACF;AAsCA,eAAsB,YAAY,SAAsD;AACtF,QAAM,EAAE,MAAM,QAAQ,eAAe,KAAK,IAAI;AAC9C,MAAI;AACF,UAAM,KAAK,KAAK,EAAE,OAAO,CAAC;AAAA,EAC5B,SAAS,OAAO;AACd,UAAM,IAAI;AAAA,MACR,6CAA6C,MAAM;AAAA,MAEnD,EAAE,MAAM;AAAA,IACV;AAAA,EACF;AAEA,MAAI,WAAW,KAAK,WAAW;AAC/B,MAAI,gBAAgB,CAAC,UAAU;AAC7B,SAAK,MAAM;AAEX,eAAW,KAAK,WAAW;AAAA,EAC7B;AAEA,QAAM,WAAW,KAAK,WAAW;AACjC,QAAM,QAAyB,WAAW,SAAS,iBAAiB,IAAI,QAAQ;AAChF,QAAM,UAAU,WAAW,KAAK,WAAW,IAAI;AAE/C,QAAM,OAAO,QAAQ,iBAAiB,OAAO,aAAa,cAAc,SAAS,OAAO;AACxF,MAAI,WAA6B;AACjC,MAAI,SAAS,QAAW;AACtB,QAAI;AACF,iBAAW,eAAe,IAAI;AAAA,IAChC,SAAS,KAAK;AACZ,UAAI,EAAE,eAAe,qBAAsB,OAAM;AACjD,iBAAW;AAAA,IACb;AAAA,EACF;AAEA,SAAO,EAAE,OAAO,UAAU,SAAS,UAAU,SAAS;AACxD;AAQA,SAAS,mBAA4B;AACnC,MAAI;AACF,QAAI,OAAO,eAAe,cAAc,WAAW,4BAA4B,EAAE,SAAS;AACxF,aAAO;AAAA,IACT;AAAA,EACF,QAAQ;AAAA,EAER;AACA,QAAM,MAAM,OAAO,cAAc,cAC5B,YACD;AACJ,SAAO,KAAK,eAAe;AAC7B;AAwBO,SAAS,gBACd,MACA,UAAkC,CAAC,GACpB;AACf,QAAM,EAAE,YAAY,SAAS,cAAc,GAAG,IAAI;AAClD,QAAM,QAAQ,KAAK,WAAW,IAAI,KAAK,WAAW,IAAI;AAEtD,MAAI,UAAU,MAAM;AAClB,UAAM,MAAM,aAAa,KAAK;AAC9B,QAAI,QAAQ,MAAM;AAChB,YAAM,IAAI;AAAA,QACR;AAAA,QACA;AAAA,MACF;AAAA,IACF;AACA,QAAI,MAAM,MAAO,KAAK,IAAI,IAAI,cAAc,IAAM,QAAO;AAAA,EAC3D;AAEA,MAAI,cAAc,SAAS;AACzB,UAAM,IAAI;AAAA,MACR;AAAA,MACA;AAAA,IACF;AAAA,EACF;AACA,OAAK,MAAM;AACX,SAAO;AACT;AAGA,SAAS,aAAa,OAA8B;AAClD,QAAM,QAAQ,MAAM,MAAM,GAAG;AAC7B,MAAI,MAAM,WAAW,EAAG,QAAO;AAC/B,MAAI;AACF,UAAM,UAAU,MAAM,CAAC,EAAG,QAAQ,MAAM,GAAG,EAAE,QAAQ,MAAM,GAAG;AAC9D,UAAM,OAAO,KAAK,MAAM,KAAK,OAAO,CAAC;AACrC,WAAO,OAAO,KAAK,QAAQ,YAAY,OAAO,SAAS,KAAK,GAAG,IAAI,KAAK,MAAM;AAAA,EAChF,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAiBO,SAAS,aAAa,MAAgB,KAAmB;AAC9D,OAAK,WAAW,EAAE,KAAK,UAAU,KAAK,CAAC;AACzC;","names":[]}
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@noy-db/in-liff",
3
+ "version": "0.4.0-pre.2",
4
+ "description": "LIFF (LINE Front-end Framework) shell binding for noy-db — boot + shell context detection across LIFF/browser/PWA, LINE ID-token lifecycle with re-login on expiry, share-link deep-link ingestion, and the external-browser escape hatch with the platform handoff matrix.",
5
+ "license": "MIT",
6
+ "author": "vLannaAi <vicio@lanna.ai>",
7
+ "homepage": "https://github.com/vLannaAi/noy-db/tree/main/packages/in-liff#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/vLannaAi/noy-db.git",
11
+ "directory": "packages/in-liff"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/vLannaAi/noy-db/issues"
15
+ },
16
+ "type": "module",
17
+ "sideEffects": false,
18
+ "exports": {
19
+ ".": {
20
+ "types": "./dist/index.d.ts",
21
+ "default": "./dist/index.js"
22
+ }
23
+ },
24
+ "module": "./dist/index.js",
25
+ "types": "./dist/index.d.ts",
26
+ "files": [
27
+ "dist",
28
+ "README.md",
29
+ "LICENSE"
30
+ ],
31
+ "engines": {
32
+ "node": ">=22.0.0"
33
+ },
34
+ "peerDependencies": {
35
+ "@noy-db/hub": "0.4.0-pre.2"
36
+ },
37
+ "devDependencies": {
38
+ "@noy-db/hub": "0.4.0-pre.2"
39
+ },
40
+ "keywords": [
41
+ "noy-db",
42
+ "in-liff",
43
+ "liff",
44
+ "line",
45
+ "line-login",
46
+ "deep-link"
47
+ ],
48
+ "publishConfig": {
49
+ "access": "public",
50
+ "tag": "latest"
51
+ },
52
+ "scripts": {
53
+ "build": "tsup",
54
+ "test": "vitest run",
55
+ "lint": "eslint src/",
56
+ "typecheck": "tsc --noEmit"
57
+ }
58
+ }