@moxn/kb-migrate 0.4.28 → 0.4.30

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.
@@ -0,0 +1,136 @@
1
+ /**
2
+ * MSAL wrapper for OneNote / Microsoft Graph OAuth.
3
+ *
4
+ * We use MSAL's ConfidentialClientApplication and let it handle:
5
+ * - code-for-token exchange
6
+ * - silent refresh via the stored token cache
7
+ * - cache serialization
8
+ *
9
+ * The serialized cache JSON is what we persist (encrypted) in
10
+ * kb_integration_credentials. On each use we deserialize, call MSAL, and
11
+ * write back any updated cache.
12
+ */
13
+ import { ConfidentialClientApplication, } from '@azure/msal-node';
14
+ /**
15
+ * Scopes we request. Read-only for v1 (see plan §OAuth design).
16
+ * `User.Read` is needed only during the initial code exchange to populate
17
+ * the credential metadata (displayName, email) — after that MSAL doesn't
18
+ * need it to refresh the OneNote access token.
19
+ */
20
+ export const ONENOTE_READ_SCOPES = [
21
+ 'https://graph.microsoft.com/Notes.Read',
22
+ 'https://graph.microsoft.com/User.Read',
23
+ 'offline_access',
24
+ ];
25
+ export const ONENOTE_GRAPH_SCOPE = ['https://graph.microsoft.com/Notes.Read'];
26
+ function buildConfig(cfg, cacheBlob) {
27
+ return {
28
+ auth: {
29
+ clientId: cfg.clientId,
30
+ clientSecret: cfg.clientSecret,
31
+ authority: `https://login.microsoftonline.com/${cfg.tenant}`,
32
+ },
33
+ cache: cacheBlob
34
+ ? {
35
+ cachePlugin: {
36
+ beforeCacheAccess: async (context) => {
37
+ context.tokenCache.deserialize(cacheBlob);
38
+ },
39
+ afterCacheAccess: async () => {
40
+ /* no-op; caller reads serialize() after the call */
41
+ },
42
+ },
43
+ }
44
+ : undefined,
45
+ };
46
+ }
47
+ /**
48
+ * Exchange an authorization code (plus PKCE verifier) for tokens, and return
49
+ * an encrypted-cache-ready payload. Called once from the OAuth callback.
50
+ */
51
+ export async function exchangeCodeForTokens(cfg, params) {
52
+ const cca = new ConfidentialClientApplication(buildConfig(cfg));
53
+ const result = await cca.acquireTokenByCode({
54
+ code: params.code,
55
+ redirectUri: cfg.redirectUri,
56
+ scopes: params.scopes ?? ONENOTE_READ_SCOPES,
57
+ codeVerifier: params.codeVerifier,
58
+ });
59
+ if (!result) {
60
+ throw new Error('MSAL returned no result from code exchange');
61
+ }
62
+ const cacheBlob = cca.getTokenCache().serialize();
63
+ const account = result.account;
64
+ if (!account) {
65
+ throw new Error('MSAL result missing account — cannot store credential');
66
+ }
67
+ return {
68
+ accessToken: result.accessToken,
69
+ cacheBlob,
70
+ accountId: account.homeAccountId,
71
+ userPrincipalName: account.username,
72
+ tenantId: account.tenantId,
73
+ displayName: account.name,
74
+ expiresOn: result.expiresOn?.toISOString(),
75
+ };
76
+ }
77
+ /**
78
+ * Silently acquire an access token using a persisted MSAL cache.
79
+ * Auto-refreshes if the access token is expired (needs offline_access in cache).
80
+ */
81
+ export async function getAccessTokenSilent(cfg, params) {
82
+ const cca = new ConfidentialClientApplication(buildConfig(cfg, params.cacheBlob));
83
+ const cache = cca.getTokenCache();
84
+ const account = await cache.getAccountByHomeId(params.accountId);
85
+ if (!account) {
86
+ throw new Error(`No cached account for homeId ${params.accountId}. User must reconnect.`);
87
+ }
88
+ const result = await cca.acquireTokenSilent({
89
+ account,
90
+ scopes: params.scopes ?? ONENOTE_GRAPH_SCOPE,
91
+ });
92
+ if (!result) {
93
+ throw new Error('MSAL silent token acquisition returned no result');
94
+ }
95
+ const freshCache = cache.serialize();
96
+ const updatedCacheBlob = freshCache !== params.cacheBlob ? freshCache : null;
97
+ return {
98
+ accessToken: result.accessToken,
99
+ updatedCacheBlob,
100
+ expiresOn: result.expiresOn?.toISOString(),
101
+ };
102
+ }
103
+ /**
104
+ * Build the Microsoft authorize URL for the popup/redirect. Called from
105
+ * the "Connect OneNote" server action.
106
+ */
107
+ export async function buildAuthorizeUrl(cfg, params) {
108
+ const cca = new ConfidentialClientApplication(buildConfig(cfg));
109
+ return cca.getAuthCodeUrl({
110
+ redirectUri: cfg.redirectUri,
111
+ scopes: params.scopes ?? ONENOTE_READ_SCOPES,
112
+ state: params.state,
113
+ codeChallenge: params.codeChallenge,
114
+ codeChallengeMethod: 'S256',
115
+ responseMode: 'query',
116
+ });
117
+ }
118
+ // ============================================
119
+ // PKCE helpers (pure, no MSAL dependency)
120
+ // ============================================
121
+ import { createHash, randomBytes } from 'crypto';
122
+ export function generatePkcePair() {
123
+ const verifier = base64UrlEncode(randomBytes(32));
124
+ const challenge = base64UrlEncode(createHash('sha256').update(verifier).digest());
125
+ return { verifier, challenge };
126
+ }
127
+ export function generateOAuthState() {
128
+ return base64UrlEncode(randomBytes(32));
129
+ }
130
+ function base64UrlEncode(buf) {
131
+ return buf
132
+ .toString('base64')
133
+ .replace(/\+/g, '-')
134
+ .replace(/\//g, '_')
135
+ .replace(/=/g, '');
136
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Convert OneNote page HTML into Moxn SectionInput[].
3
+ *
4
+ * Contract:
5
+ * - Input: a page's HTML body as returned by GET /me/onenote/pages/{id}/content
6
+ * (with ?includeIDs=true, so each element has a stable data-id).
7
+ * - Output: zero or more SectionInput. Content before the first <h2> lands
8
+ * in an "Introduction" section; each <h2> starts a new section.
9
+ *
10
+ * This is the highest-risk part of the import — OneNote HTML is idiosyncratic
11
+ * (absolute positioning, data-tag semantics, citation footers). The function
12
+ * is pure: no network, no filesystem. It consumes an ImageResolver callback
13
+ * that the fan-out task wires to a Supabase-upload pipeline.
14
+ *
15
+ * Images: the converter does NOT fetch images. It calls `resolveMedia` for
16
+ * every <img>/<object> and expects back a storage key. The resolver is free
17
+ * to return null to indicate "skip this embed".
18
+ */
19
+ import type { ConvertedOneNotePage } from './types.js';
20
+ export type MediaKind = 'image' | 'file';
21
+ export interface ResolvedMedia {
22
+ /** Supabase storage key, e.g. "kb/onenote/2026/abc.png" */
23
+ key: string;
24
+ mediaType: string;
25
+ /** Filename for attachments (not required for images). */
26
+ filename?: string;
27
+ }
28
+ export interface ResolveMediaContext {
29
+ kind: MediaKind;
30
+ /** `src` (for <img>) or `data` (for <object>) */
31
+ url: string;
32
+ /** Parsed MIME type from the element, if present */
33
+ declaredMimeType?: string;
34
+ /** Filename parsed from <object data-attachment="..."> */
35
+ declaredFilename?: string;
36
+ /** Alt text from <img alt="..."> */
37
+ alt?: string;
38
+ }
39
+ export type ResolveMediaFn = (ctx: ResolveMediaContext) => Promise<ResolvedMedia | null>;
40
+ export interface ConvertOptions {
41
+ /**
42
+ * Strip OneNote's "Copied from ..." citation footer rather than rendering
43
+ * it. Default: render as a blockquote at section end.
44
+ */
45
+ dropCitations?: boolean;
46
+ /**
47
+ * Called for every image and object the converter encounters. Returns a
48
+ * storage key, or null to skip the embed entirely.
49
+ */
50
+ resolveMedia: ResolveMediaFn;
51
+ /** Optional page title override — overrides the page's own <title> / <h1>. */
52
+ titleOverride?: string;
53
+ }
54
+ export declare function convertOneNoteHtmlToSections(html: string, options: ConvertOptions): Promise<ConvertedOneNotePage>;