@rockhopper-co/mcp-server 2.0.1 → 2.1.1

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 (54) hide show
  1. package/README.md +2 -1
  2. package/dist/api-client.d.ts +136 -2
  3. package/dist/api-client.d.ts.map +1 -1
  4. package/dist/api-client.js +185 -2
  5. package/dist/api-client.js.map +1 -1
  6. package/dist/capabilities.d.ts +5 -6
  7. package/dist/capabilities.d.ts.map +1 -1
  8. package/dist/capabilities.js +6 -9
  9. package/dist/capabilities.js.map +1 -1
  10. package/dist/drive-search.d.ts +162 -0
  11. package/dist/drive-search.d.ts.map +1 -0
  12. package/dist/drive-search.js +210 -0
  13. package/dist/drive-search.js.map +1 -0
  14. package/dist/enrollment.d.ts +121 -0
  15. package/dist/enrollment.d.ts.map +1 -0
  16. package/dist/enrollment.js +247 -0
  17. package/dist/enrollment.js.map +1 -0
  18. package/dist/resources/orchestration-guide.md +58 -8
  19. package/dist/tools/connect-microsoft.d.ts +21 -0
  20. package/dist/tools/connect-microsoft.d.ts.map +1 -0
  21. package/dist/tools/connect-microsoft.js +138 -0
  22. package/dist/tools/connect-microsoft.js.map +1 -0
  23. package/dist/tools/drive-search-lanes.d.ts +65 -0
  24. package/dist/tools/drive-search-lanes.d.ts.map +1 -0
  25. package/dist/tools/drive-search-lanes.js +95 -0
  26. package/dist/tools/drive-search-lanes.js.map +1 -0
  27. package/dist/tools/drive-search.contract.d.ts +94 -0
  28. package/dist/tools/drive-search.contract.d.ts.map +1 -0
  29. package/dist/tools/drive-search.contract.js +235 -0
  30. package/dist/tools/drive-search.contract.js.map +1 -0
  31. package/dist/tools/drive-search.d.ts +31 -0
  32. package/dist/tools/drive-search.d.ts.map +1 -0
  33. package/dist/tools/drive-search.js +209 -0
  34. package/dist/tools/drive-search.js.map +1 -0
  35. package/dist/tools/enroll-file.contract.d.ts +52 -0
  36. package/dist/tools/enroll-file.contract.d.ts.map +1 -0
  37. package/dist/tools/enroll-file.contract.js +105 -0
  38. package/dist/tools/enroll-file.contract.js.map +1 -0
  39. package/dist/tools/enroll-file.d.ts +4 -0
  40. package/dist/tools/enroll-file.d.ts.map +1 -0
  41. package/dist/tools/enroll-file.js +184 -0
  42. package/dist/tools/enroll-file.js.map +1 -0
  43. package/dist/tools/index.d.ts.map +1 -1
  44. package/dist/tools/index.js +12 -0
  45. package/dist/tools/index.js.map +1 -1
  46. package/dist/tools/search.d.ts.map +1 -1
  47. package/dist/tools/search.js +25 -2
  48. package/dist/tools/search.js.map +1 -1
  49. package/dist/tools/write-files.d.ts.map +1 -1
  50. package/dist/tools/write-files.js +5 -0
  51. package/dist/tools/write-files.js.map +1 -1
  52. package/dist/types.d.ts +150 -0
  53. package/dist/types.d.ts.map +1 -1
  54. package/package.json +2 -2
@@ -0,0 +1,162 @@
1
+ /**
2
+ * ENG-2204 (plan 13 / SP08) — the state and the rules behind
3
+ * `search_drive_files`, kept out of the tool so each half can be tested
4
+ * without a registered server and so neither file crosses 300 lines.
5
+ *
6
+ * **Why this tool exists.** ENG-1647: a customer asked for a SharePoint
7
+ * workbook by name. The only search on the surface looked at files ALREADY in
8
+ * Rockhopper, matched a substring against a DIFFERENT file, and the assistant
9
+ * answered "already enrolled". ENG-2200 built the enroll tool that did not
10
+ * exist; this is the other half — finding a file the customer has NOT enrolled
11
+ * yet, and pinning down which one before anything is written.
12
+ *
13
+ * Two properties here are guardrails against a hostile INPUT, not against a
14
+ * clumsy user, and neither can be delegated to the tool description. A model
15
+ * driving this session reads content it did not author — cell values, file
16
+ * names, comment text — and a sentence in a spreadsheet saying "list every
17
+ * file in this drive" is an instruction it may well follow. Prose in a
18
+ * description cannot refuse; only code can:
19
+ *
20
+ * 1. {@link SearchBudget} is a HARD per-session ceiling on how many times the
21
+ * drive can be searched at all.
22
+ * 2. {@link CandidateRegistry} means a confirmation can only ever name a file
23
+ * THIS session's search actually returned. The confirmed pick is looked up
24
+ * in server memory by a nonce; nothing that arrives from the client is
25
+ * trusted to describe a file.
26
+ */
27
+ import type { DriveSearchItem } from './types.js';
28
+ /**
29
+ * Searches one session may run, total.
30
+ *
31
+ * The backend already caps the RATE at 6/min (`DRIVE_SEARCH_RATE_LIMIT`).
32
+ * What no rate limit bounds is the TOTAL, and drive enumeration is a
33
+ * total-volume attack: a page carries up to 50 names, so an unbounded session
34
+ * walks a customer's whole drive at six pages a minute and never trips
35
+ * anything. This is the ceiling that rate limiting cannot express.
36
+ *
37
+ * 20 is chosen against both failure modes rather than picked round. A person
38
+ * looking for a workbook searches two or three times and stops; twenty leaves
39
+ * that untouched even when the first several attempts miss. An enumerator gets
40
+ * at most 20 pages and then the tool stops answering for the rest of the
41
+ * session — a bound the attacker cannot lift by waiting, which is exactly what
42
+ * distinguishes it from the backend's per-minute bucket.
43
+ */
44
+ export declare const DRIVE_SEARCH_SESSION_BUDGET = 20;
45
+ /** Every answer `search_drive_files` can give, as a value a model branches on. */
46
+ export type DriveSearchOutcome =
47
+ /** Candidates found; the user must confirm which one before enrolling. */
48
+ 'candidates'
49
+ /** The search ran and Microsoft returned nothing. */
50
+ | 'no_matches'
51
+ /** The user picked one; its identifiers are in the result, ready to enroll. */
52
+ | 'confirmed'
53
+ /** The user was asked and said no. Nothing was searched further or written. */
54
+ | 'declined'
55
+ /** This session has used its whole search budget. Refused BEFORE the call. */
56
+ | 'search_limit_reached'
57
+ /** No delegated Microsoft grant — the connect link is in the result. */
58
+ | 'microsoft_not_connected'
59
+ /**
60
+ * The tenant has not approved Rockhopper and only an administrator can.
61
+ * Its OWN outcome and not a flavour of `microsoft_not_connected`, because
62
+ * the two name opposite actions: one is the user's to take and this one
63
+ * is not. Carries no connect link — there is nothing on the far end of
64
+ * one for this user yet.
65
+ */
66
+ | 'microsoft_admin_approval_required'
67
+ /** Microsoft could not answer, or the caller is searching too fast. */
68
+ | 'search_unavailable'
69
+ /** This Rockhopper deployment serves no drive-search route yet. */
70
+ | 'backend_unsupported'
71
+ /** The confirmation named a file this session's search never returned. */
72
+ | 'unknown_candidate';
73
+ /**
74
+ * A hard per-session ceiling on drive searches.
75
+ *
76
+ * One instance per `createServer`, so the count belongs to the session and not
77
+ * to the module — two servers in one test process must not share a budget, and
78
+ * a module-level counter is exactly the shape that silently does.
79
+ */
80
+ export declare class SearchBudget {
81
+ private readonly limit;
82
+ private used;
83
+ constructor(limit?: number);
84
+ get spent(): number;
85
+ get ceiling(): number;
86
+ /**
87
+ * Claim one search. `false` means the session is done searching, and the
88
+ * caller must not reach the network — the point of the cap is that the
89
+ * request never leaves, not that its answer is discarded.
90
+ */
91
+ claim(): boolean;
92
+ }
93
+ /** One candidate, plus the identity `enroll_file` needs to act on it. */
94
+ export interface Candidate {
95
+ msId: string;
96
+ driveMsId: string | null;
97
+ name: string;
98
+ parentPath: string | null;
99
+ lastModifiedAt: string | null;
100
+ enrollmentState: DriveSearchItem['enrollmentState'];
101
+ }
102
+ /**
103
+ * The candidate sets THIS session has actually seen, keyed by a nonce.
104
+ *
105
+ * Why server memory rather than the round-trip. The 2026-07-28 confirmation
106
+ * lane hands the client a `requestState` string and gets it back on retry, and
107
+ * the SDK is explicit that the returned value is attacker-controlled: it
108
+ * travels through the client, nothing signs it, and `requestState()` hands
109
+ * back whatever came in. Putting the candidate list in there would mean a
110
+ * confirmation could describe ANY file — including one Microsoft never
111
+ * returned for this user — and the tool would then hand its identifiers to
112
+ * `enroll_file`. So the wire carries a lookup key and nothing else, and the
113
+ * files it can name are only ever the ones {@link remember} was given.
114
+ *
115
+ * Bounded because a long-lived stdio session is a long-lived process: only the
116
+ * most recent {@link MAX_REMEMBERED} sets are kept.
117
+ */
118
+ export declare class CandidateRegistry {
119
+ static readonly MAX_REMEMBERED = 8;
120
+ private readonly sets;
121
+ remember(nonce: string, candidates: readonly Candidate[]): void;
122
+ /**
123
+ * The candidate this session offered under `nonce` at `index`, or `null`.
124
+ *
125
+ * `null` for an unknown nonce and for an out-of-range index alike: both mean
126
+ * the confirmation is describing something this session did not offer, and
127
+ * the caller's answer to that is the same refusal.
128
+ */
129
+ resolve(nonce: string, index: number): Candidate | null;
130
+ /** The set offered under `nonce`, for re-rendering the question on retry. */
131
+ recall(nonce: string): readonly Candidate[] | null;
132
+ }
133
+ /** Turn a backend item into a candidate, dropping fields nothing renders. */
134
+ export declare function toCandidate(item: DriveSearchItem): Candidate;
135
+ export interface ClassifiedSearchFailure {
136
+ outcome: DriveSearchOutcome;
137
+ message: string;
138
+ }
139
+ /**
140
+ * Turn a thrown API error into one of the named outcomes.
141
+ *
142
+ * Keyed on the backend's `code` first and the status only as a fallback, for
143
+ * the same reason `classifyEnrollmentFailure` is: the codes name different
144
+ * remedies and the statuses do not. `NO_DELEGATED_TOKEN` is the only one whose
145
+ * remedy is the user's to take, and it is handled by the caller rather than
146
+ * here, because answering it means MINTING a connect link.
147
+ */
148
+ export declare function classifyDriveSearchFailure(error: unknown): ClassifiedSearchFailure;
149
+ /** The backend's one refusal that means "the user must connect Microsoft". */
150
+ export declare function isNoDelegatedToken(error: unknown): boolean;
151
+ export declare function isAdminConsentRequired(error: unknown): boolean;
152
+ /**
153
+ * What the assistant is told when an administrator has to act.
154
+ *
155
+ * Names the ACTION and who takes it, and says plainly that retrying will not
156
+ * work — a model that reads "could not connect" will helpfully offer to try
157
+ * again, and every retry costs the user another dead end. It also does not
158
+ * hand out a connect link, because there is nothing on the other end of one
159
+ * for this user yet.
160
+ */
161
+ export declare const ADMIN_CONSENT_TEXT: string;
162
+ //# sourceMappingURL=drive-search.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"drive-search.d.ts","sourceRoot":"","sources":["../src/drive-search.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAGH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAElD;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,2BAA2B,KAAK,CAAC;AAE9C,kFAAkF;AAClF,MAAM,MAAM,kBAAkB;AAC5B,0EAA0E;AACxE,YAAY;AACd,qDAAqD;GACnD,YAAY;AACd,+EAA+E;GAC7E,WAAW;AACb,+EAA+E;GAC7E,UAAU;AACZ,8EAA8E;GAC5E,sBAAsB;AACxB,wEAAwE;GACtE,yBAAyB;AAC3B;;;;;;GAMG;GACD,mCAAmC;AACrC,uEAAuE;GACrE,oBAAoB;AACtB,mEAAmE;GACjE,qBAAqB;AACvB,0EAA0E;GACxE,mBAAmB,CAAC;AAExB;;;;;;GAMG;AACH,qBAAa,YAAY;IAGX,OAAO,CAAC,QAAQ,CAAC,KAAK;IAFlC,OAAO,CAAC,IAAI,CAAK;gBAEY,KAAK,GAAE,MAAoC;IAExE,IAAI,KAAK,IAAI,MAAM,CAElB;IAED,IAAI,OAAO,IAAI,MAAM,CAEpB;IAED;;;;OAIG;IACH,KAAK,IAAI,OAAO;CAKjB;AAED,yEAAyE;AACzE,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,eAAe,EAAE,eAAe,CAAC,iBAAiB,CAAC,CAAC;CACrD;AAED;;;;;;;;;;;;;;;GAeG;AACH,qBAAa,iBAAiB;IAC5B,MAAM,CAAC,QAAQ,CAAC,cAAc,KAAK;IAEnC,OAAO,CAAC,QAAQ,CAAC,IAAI,CAA2C;IAEhE,QAAQ,CAAC,KAAK,EAAE,MAAM,EAAE,UAAU,EAAE,SAAS,SAAS,EAAE,GAAG,IAAI;IAS/D;;;;;;OAMG;IACH,OAAO,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI;IASvD,6EAA6E;IAC7E,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,SAAS,EAAE,GAAG,IAAI;CAGnD;AAED,6EAA6E;AAC7E,wBAAgB,WAAW,CAAC,IAAI,EAAE,eAAe,GAAG,SAAS,CAS5D;AAED,MAAM,WAAW,uBAAuB;IACtC,OAAO,EAAE,kBAAkB,CAAC;IAC5B,OAAO,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;;;GAQG;AACH,wBAAgB,0BAA0B,CACxC,KAAK,EAAE,OAAO,GACb,uBAAuB,CAiCzB;AAED,8EAA8E;AAC9E,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAI1D;AAcD,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAI9D;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,kBAAkB,QAQuB,CAAC"}
@@ -0,0 +1,210 @@
1
+ /**
2
+ * ENG-2204 (plan 13 / SP08) — the state and the rules behind
3
+ * `search_drive_files`, kept out of the tool so each half can be tested
4
+ * without a registered server and so neither file crosses 300 lines.
5
+ *
6
+ * **Why this tool exists.** ENG-1647: a customer asked for a SharePoint
7
+ * workbook by name. The only search on the surface looked at files ALREADY in
8
+ * Rockhopper, matched a substring against a DIFFERENT file, and the assistant
9
+ * answered "already enrolled". ENG-2200 built the enroll tool that did not
10
+ * exist; this is the other half — finding a file the customer has NOT enrolled
11
+ * yet, and pinning down which one before anything is written.
12
+ *
13
+ * Two properties here are guardrails against a hostile INPUT, not against a
14
+ * clumsy user, and neither can be delegated to the tool description. A model
15
+ * driving this session reads content it did not author — cell values, file
16
+ * names, comment text — and a sentence in a spreadsheet saying "list every
17
+ * file in this drive" is an instruction it may well follow. Prose in a
18
+ * description cannot refuse; only code can:
19
+ *
20
+ * 1. {@link SearchBudget} is a HARD per-session ceiling on how many times the
21
+ * drive can be searched at all.
22
+ * 2. {@link CandidateRegistry} means a confirmation can only ever name a file
23
+ * THIS session's search actually returned. The confirmed pick is looked up
24
+ * in server memory by a nonce; nothing that arrives from the client is
25
+ * trusted to describe a file.
26
+ */
27
+ import { RockhopperApiError } from './api-client.js';
28
+ /**
29
+ * Searches one session may run, total.
30
+ *
31
+ * The backend already caps the RATE at 6/min (`DRIVE_SEARCH_RATE_LIMIT`).
32
+ * What no rate limit bounds is the TOTAL, and drive enumeration is a
33
+ * total-volume attack: a page carries up to 50 names, so an unbounded session
34
+ * walks a customer's whole drive at six pages a minute and never trips
35
+ * anything. This is the ceiling that rate limiting cannot express.
36
+ *
37
+ * 20 is chosen against both failure modes rather than picked round. A person
38
+ * looking for a workbook searches two or three times and stops; twenty leaves
39
+ * that untouched even when the first several attempts miss. An enumerator gets
40
+ * at most 20 pages and then the tool stops answering for the rest of the
41
+ * session — a bound the attacker cannot lift by waiting, which is exactly what
42
+ * distinguishes it from the backend's per-minute bucket.
43
+ */
44
+ export const DRIVE_SEARCH_SESSION_BUDGET = 20;
45
+ /**
46
+ * A hard per-session ceiling on drive searches.
47
+ *
48
+ * One instance per `createServer`, so the count belongs to the session and not
49
+ * to the module — two servers in one test process must not share a budget, and
50
+ * a module-level counter is exactly the shape that silently does.
51
+ */
52
+ export class SearchBudget {
53
+ limit;
54
+ used = 0;
55
+ constructor(limit = DRIVE_SEARCH_SESSION_BUDGET) {
56
+ this.limit = limit;
57
+ }
58
+ get spent() {
59
+ return this.used;
60
+ }
61
+ get ceiling() {
62
+ return this.limit;
63
+ }
64
+ /**
65
+ * Claim one search. `false` means the session is done searching, and the
66
+ * caller must not reach the network — the point of the cap is that the
67
+ * request never leaves, not that its answer is discarded.
68
+ */
69
+ claim() {
70
+ if (this.used >= this.limit)
71
+ return false;
72
+ this.used += 1;
73
+ return true;
74
+ }
75
+ }
76
+ /**
77
+ * The candidate sets THIS session has actually seen, keyed by a nonce.
78
+ *
79
+ * Why server memory rather than the round-trip. The 2026-07-28 confirmation
80
+ * lane hands the client a `requestState` string and gets it back on retry, and
81
+ * the SDK is explicit that the returned value is attacker-controlled: it
82
+ * travels through the client, nothing signs it, and `requestState()` hands
83
+ * back whatever came in. Putting the candidate list in there would mean a
84
+ * confirmation could describe ANY file — including one Microsoft never
85
+ * returned for this user — and the tool would then hand its identifiers to
86
+ * `enroll_file`. So the wire carries a lookup key and nothing else, and the
87
+ * files it can name are only ever the ones {@link remember} was given.
88
+ *
89
+ * Bounded because a long-lived stdio session is a long-lived process: only the
90
+ * most recent {@link MAX_REMEMBERED} sets are kept.
91
+ */
92
+ export class CandidateRegistry {
93
+ static MAX_REMEMBERED = 8;
94
+ sets = new Map();
95
+ remember(nonce, candidates) {
96
+ this.sets.set(nonce, candidates);
97
+ while (this.sets.size > CandidateRegistry.MAX_REMEMBERED) {
98
+ const oldest = this.sets.keys().next().value;
99
+ if (oldest === undefined)
100
+ break;
101
+ this.sets.delete(oldest);
102
+ }
103
+ }
104
+ /**
105
+ * The candidate this session offered under `nonce` at `index`, or `null`.
106
+ *
107
+ * `null` for an unknown nonce and for an out-of-range index alike: both mean
108
+ * the confirmation is describing something this session did not offer, and
109
+ * the caller's answer to that is the same refusal.
110
+ */
111
+ resolve(nonce, index) {
112
+ const set = this.sets.get(nonce);
113
+ if (!set)
114
+ return null;
115
+ if (!Number.isInteger(index) || index < 0 || index >= set.length) {
116
+ return null;
117
+ }
118
+ return set[index];
119
+ }
120
+ /** The set offered under `nonce`, for re-rendering the question on retry. */
121
+ recall(nonce) {
122
+ return this.sets.get(nonce) ?? null;
123
+ }
124
+ }
125
+ /** Turn a backend item into a candidate, dropping fields nothing renders. */
126
+ export function toCandidate(item) {
127
+ return {
128
+ msId: item.msId,
129
+ driveMsId: item.driveMsId,
130
+ name: item.name,
131
+ parentPath: item.parentPath,
132
+ lastModifiedAt: item.lastModifiedAt,
133
+ enrollmentState: item.enrollmentState,
134
+ };
135
+ }
136
+ /**
137
+ * Turn a thrown API error into one of the named outcomes.
138
+ *
139
+ * Keyed on the backend's `code` first and the status only as a fallback, for
140
+ * the same reason `classifyEnrollmentFailure` is: the codes name different
141
+ * remedies and the statuses do not. `NO_DELEGATED_TOKEN` is the only one whose
142
+ * remedy is the user's to take, and it is handled by the caller rather than
143
+ * here, because answering it means MINTING a connect link.
144
+ */
145
+ export function classifyDriveSearchFailure(error) {
146
+ if (error instanceof RockhopperApiError) {
147
+ // The npm package and the backend ship on separate clocks and a customer
148
+ // running `npx` picks up `latest` the moment it publishes, so calling a
149
+ // deployment that predates the route is a real case, not a theoretical
150
+ // one. It must not read as "you have no files".
151
+ if (error.status === 404) {
152
+ return {
153
+ outcome: 'backend_unsupported',
154
+ message: 'This Rockhopper deployment cannot search Microsoft files from an ' +
155
+ 'assistant yet. Ask the user to paste the workbook\'s SharePoint or ' +
156
+ 'OneDrive link and call `enroll_file` with it instead.',
157
+ };
158
+ }
159
+ if (error.status === 400) {
160
+ return {
161
+ outcome: 'search_unavailable',
162
+ message: 'The search needs something to look for. Ask the user for part of ' +
163
+ 'the file name, or call again with scope="recent" to list the files ' +
164
+ 'they have worked on lately.',
165
+ };
166
+ }
167
+ }
168
+ return {
169
+ outcome: 'search_unavailable',
170
+ message: 'Microsoft could not answer the file search just now. This is not an ' +
171
+ 'empty drive — nothing was searched. Try again in a moment, or ask the ' +
172
+ 'user to paste the workbook link and call `enroll_file` with it.',
173
+ };
174
+ }
175
+ /** The backend's one refusal that means "the user must connect Microsoft". */
176
+ export function isNoDelegatedToken(error) {
177
+ return (error instanceof RockhopperApiError && error.code === 'NO_DELEGATED_TOKEN');
178
+ }
179
+ /**
180
+ * The backend's `reason` for a refusal the USER CANNOT FIX (ENG-2614).
181
+ *
182
+ * The tenant has not approved Rockhopper, and Microsoft will not let an
183
+ * ordinary employee approve it — `Sites.Read.All` is administrator-only. So
184
+ * this arrives wearing the same coarse `NO_DELEGATED_TOKEN` code as "you
185
+ * have never connected", and treating the two alike is how the user ends up
186
+ * in a loop: handed a connect link, sent to Microsoft, refused, handed the
187
+ * same link again. Only the fine `reason` separates them.
188
+ */
189
+ const CONSENT_REQUIRED = 'CONSENT_REQUIRED';
190
+ export function isAdminConsentRequired(error) {
191
+ return (error instanceof RockhopperApiError && error.reason === CONSENT_REQUIRED);
192
+ }
193
+ /**
194
+ * What the assistant is told when an administrator has to act.
195
+ *
196
+ * Names the ACTION and who takes it, and says plainly that retrying will not
197
+ * work — a model that reads "could not connect" will helpfully offer to try
198
+ * again, and every retry costs the user another dead end. It also does not
199
+ * hand out a connect link, because there is nothing on the other end of one
200
+ * for this user yet.
201
+ */
202
+ export const ADMIN_CONSENT_TEXT = 'Rockhopper cannot search this user\'s Microsoft files because their ' +
203
+ 'organisation has not approved Rockhopper yet. Microsoft only lets a ' +
204
+ 'Microsoft 365 administrator approve this — the user cannot grant it ' +
205
+ 'themselves, and connecting or signing in again will not change the ' +
206
+ 'answer. Ask the user to send their IT administrator to ' +
207
+ 'https://docs.rockhopper.co/it-setup/approve-file-access, which has ' +
208
+ 'the approval link and what it grants. Everything else in Rockhopper ' +
209
+ 'keeps working meanwhile. Do not retry this search.';
210
+ //# sourceMappingURL=drive-search.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"drive-search.js","sourceRoot":"","sources":["../src/drive-search.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,121 @@
1
+ /**
2
+ * ENG-2200 (plan 13 / SP03) — the vocabulary and the refusal mapping behind
3
+ * `enroll_file`, kept out of the tool so the branch table can be tested
4
+ * without a registered server and so neither file crosses 300 lines.
5
+ *
6
+ * **Why this tool exists.** A customer asked Claude to enroll a SharePoint
7
+ * workbook. A name-substring search matched a DIFFERENT, already-enrolled file
8
+ * and the assistant answered "already enrolled". Told it had the wrong file,
9
+ * it enumerated its tools and found no enroll tool existed at all (ENG-1647).
10
+ * Two failures: a wrong match reported as a fact, and a dead end. Every
11
+ * decision below is aimed at one of those — an outcome is never inferred from
12
+ * a name, and no path ends without a next action the user can take.
13
+ */
14
+ import type { EnrollmentState, RockhopperId, ServerEnrollmentOutcome, Team, UserSummary } from './types.js';
15
+ /**
16
+ * Every answer `enroll_file` can give, as a value a model can branch on
17
+ * instead of reading prose.
18
+ *
19
+ * The seven ENROLLMENT results — what happened to the file:
20
+ * - `enrolled` — accepted; the file was not here before.
21
+ * - `restored` — accepted; a file the user had removed is visible again.
22
+ * - `already_enrolled` — nothing to do; it is here and visible.
23
+ * - `access_unproven` — refused: this session has no Microsoft identity.
24
+ * - `admin_approval_required` — refused: the user's ORGANISATION has not
25
+ * approved Rockhopper. Separate from `access_unproven` because the user
26
+ * cannot fix it and must not be told to reconnect (ENG-2614).
27
+ * - `unresolvable` — refused: the link does not name a file we can find.
28
+ * - `unsupported_provider` — refused: not a Microsoft link.
29
+ *
30
+ * Three CONTROL answers — the tool needs something before it can proceed:
31
+ * - `share_with_required` — the model must ask the user who may see the file.
32
+ * - `restore_confirmation_required` — the target is hidden (D8); restoring is
33
+ * a second, explicit call.
34
+ * - `backend_unsupported` — this Rockhopper deployment has no enrollment API
35
+ * yet. Published separately from the backend, so this is reachable.
36
+ */
37
+ export type EnrollOutcome = 'enrolled' | 'restored' | 'already_enrolled' | 'access_unproven' | 'admin_approval_required' | 'unresolvable' | 'unsupported_provider' | 'share_with_required' | 'restore_confirmation_required' | 'backend_unsupported';
38
+ /** Who may see the file. Asked EVERY time; never defaulted (D5/D6). */
39
+ export type ShareWith = 'me' | 'team';
40
+ /**
41
+ * There is deliberately NO client-side host allow-list here.
42
+ *
43
+ * The obvious optimisation is to screen `docs.google.com` locally and save a
44
+ * round trip. It was written and removed: the backend's real rule is narrower
45
+ * than the plausible guess — `/(^|\.)sharepoint\.com$/` ONLY, so
46
+ * `onedrive.live.com` and `*.sharepoint.us` are refused there — and only
47
+ * Google earns `unsupported_provider`, while Dropbox and Box are
48
+ * `unresolvable`. A second copy of that rule in a package customers upgrade on
49
+ * their own schedule would answer differently from the server the moment
50
+ * either side moved, and a pre-screen that refuses a link the backend would
51
+ * have taken is invisible: the user simply cannot add their file. One rule,
52
+ * one place; {@link classifyEnrollmentFailure} maps what it says.
53
+ */
54
+ /** The outcome an already-known enrollment state implies, before any write. */
55
+ export declare function outcomeForState(state: EnrollmentState): 'already_enrolled' | 'restore_confirmation_required' | null;
56
+ /**
57
+ * ENG-2536 — turn the outcome the SERVER reported into what the user is told.
58
+ *
59
+ * Two things make this worth a function rather than a ternary at the call site.
60
+ *
61
+ * First, it is exhaustive with a `never` arm. `ServerEnrollmentOutcome` is a
62
+ * hand-kept mirror of a backend enum (this package ships over npm and cannot
63
+ * import the backend tree), so the only moment anybody notices a fourth value
64
+ * is when they edit that union — and this is what breaks and makes them handle
65
+ * it. ENG-2580 is the same shape gone wrong: fifteen if-chains where a new
66
+ * value fell into every `else` and nothing failed.
67
+ *
68
+ * Second, `already_enrolled` is the one answer that is not a promise about the
69
+ * future. Everything else here says "we are reading the workbook, come back";
70
+ * that one says nothing was written and nothing will be. Saying "Rockhopper
71
+ * reads the workbook in the background" about a file it read months ago sends
72
+ * the user to wait for an event that will never arrive — the ENG-1647 dead end
73
+ * with a friendlier voice.
74
+ */
75
+ export declare function describeServerOutcome(serverOutcome: ServerEnrollmentOutcome, fileName: string, who: string): {
76
+ outcome: EnrollOutcome;
77
+ text: string;
78
+ };
79
+ export interface ClassifiedFailure {
80
+ outcome: EnrollOutcome | 'error';
81
+ message: string;
82
+ }
83
+ /**
84
+ * Turn a thrown API error into one of the named outcomes.
85
+ *
86
+ * Keyed on the backend's `code` first and the HTTP status only as a fallback,
87
+ * because the codes each name a DIFFERENT remedy and the statuses do not: four
88
+ * distinct refusals share 403, and answering all of them "you do not have
89
+ * permission" sends three of the four users to fix the wrong thing.
90
+ */
91
+ export declare function classifyEnrollmentFailure(error: unknown): ClassifiedFailure;
92
+ /**
93
+ * The two reads `share_with: 'team'` needs. A narrow interface rather than the
94
+ * whole `ApiClient`, so the resolution can be tested without one and so this
95
+ * module never imports the client's concrete type.
96
+ */
97
+ export interface TeamDirectory {
98
+ getMe(): Promise<UserSummary>;
99
+ getTeam(teamId: RockhopperId): Promise<Team>;
100
+ }
101
+ /** Raised when `share_with: 'team'` names a team we cannot resolve. */
102
+ export declare class TeamUnresolvedError extends Error {
103
+ constructor(message: string);
104
+ }
105
+ /**
106
+ * The teammates a `share_with: 'team'` enroll fans the file out to.
107
+ *
108
+ * Mirrors the web enrollment wizard exactly — the caller's FIRST team
109
+ * membership, its roster, minus the caller — because SP04 defines "team" as
110
+ * "the wizard's default set" and two surfaces answering the same question
111
+ * differently is worse than either answer. The backend has no team-vs-private
112
+ * enum to lean on: `POST /enrolled-files/batch` takes an explicit list of
113
+ * platform ids and nothing else, so the expansion happens here or nowhere.
114
+ *
115
+ * THROWS rather than returning an empty list when there is no team or no
116
+ * teammate. Enrolling privately after the user said "my team" is a silent
117
+ * substitution of a different answer for the one they gave, and it is
118
+ * invisible: the file simply never appears for anyone else.
119
+ */
120
+ export declare function resolveTeamShareTargets(api: TeamDirectory): Promise<string[]>;
121
+ //# sourceMappingURL=enrollment.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"enrollment.d.ts","sourceRoot":"","sources":["../src/enrollment.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAGH,OAAO,KAAK,EACV,eAAe,EACf,YAAY,EACZ,uBAAuB,EACvB,IAAI,EACJ,WAAW,EACZ,MAAM,YAAY,CAAC;AAEpB;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,MAAM,MAAM,aAAa,GACrB,UAAU,GACV,UAAU,GACV,kBAAkB,GAClB,iBAAiB,GACjB,yBAAyB,GACzB,cAAc,GACd,sBAAsB,GACtB,qBAAqB,GACrB,+BAA+B,GAC/B,qBAAqB,CAAC;AAE1B,uEAAuE;AACvE,MAAM,MAAM,SAAS,GAAG,IAAI,GAAG,MAAM,CAAC;AAmBtC;;;;;;;;;;;;;GAaG;AAEH,+EAA+E;AAC/E,wBAAgB,eAAe,CAC7B,KAAK,EAAE,eAAe,GACrB,kBAAkB,GAAG,+BAA+B,GAAG,IAAI,CAI7D;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,qBAAqB,CACnC,aAAa,EAAE,uBAAuB,EACtC,QAAQ,EAAE,MAAM,EAChB,GAAG,EAAE,MAAM,GACV;IAAE,OAAO,EAAE,aAAa,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAgC1C;AAED,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC;IACjC,OAAO,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;;GAOG;AACH,wBAAgB,yBAAyB,CAAC,KAAK,EAAE,OAAO,GAAG,iBAAiB,CAwG3E;AAED;;;;GAIG;AACH,MAAM,WAAW,aAAa;IAC5B,KAAK,IAAI,OAAO,CAAC,WAAW,CAAC,CAAC;IAC9B,OAAO,CAAC,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC9C;AAED,uEAAuE;AACvE,qBAAa,mBAAoB,SAAQ,KAAK;gBAChC,OAAO,EAAE,MAAM;CAI5B;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,uBAAuB,CAC3C,GAAG,EAAE,aAAa,GACjB,OAAO,CAAC,MAAM,EAAE,CAAC,CA6BnB"}