flare-plugin-sdk 0.0.0-stage → 0.1.0-beta.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.
Files changed (27) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +131 -2
  3. package/package.json +33 -4
  4. package/skill/flare-plugin/ABI.md +334 -0
  5. package/skill/flare-plugin/README.md +204 -0
  6. package/skill/flare-plugin/SKILL.md +80 -0
  7. package/skill/flare-plugin/assets/github-template/README.md +71 -0
  8. package/skill/flare-plugin/assets/github-template/assets/icon.png +0 -0
  9. package/skill/flare-plugin/assets/github-template/manifest.json +29 -0
  10. package/skill/flare-plugin/assets/github-template/plugin.js +108 -0
  11. package/skill/flare-plugin/examples/pixelfed/README.md +78 -0
  12. package/skill/flare-plugin/examples/pixelfed/assets/icon.png +0 -0
  13. package/skill/flare-plugin/examples/pixelfed/locales/en.json +24 -0
  14. package/skill/flare-plugin/examples/pixelfed/locales/zh-Hans.json +24 -0
  15. package/skill/flare-plugin/examples/pixelfed/manifest.json +211 -0
  16. package/skill/flare-plugin/examples/pixelfed/plugin.js +793 -0
  17. package/skill/flare-plugin/flare-plugin.d.ts +501 -0
  18. package/skill/flare-plugin/pack.mjs +155 -0
  19. package/skill/flare-plugin/references/authentication.md +239 -0
  20. package/skill/flare-plugin/references/capabilities.md +88 -0
  21. package/skill/flare-plugin/references/manifest.md +192 -0
  22. package/skill/flare-plugin/references/publishing.md +111 -0
  23. package/skill/flare-plugin/references/recipes.md +328 -0
  24. package/skill/flare-plugin/references/runtime-http.md +207 -0
  25. package/skill/flare-plugin/references/testing.md +176 -0
  26. package/skill/flare-plugin/references/troubleshooting.md +116 -0
  27. package/skill/flare-plugin/references/wire-data.md +163 -0
@@ -0,0 +1,501 @@
1
+ /** Flare Social Plugin API v1. Ambient editor types; do not import at runtime.
2
+ * See ABI.md and references/ for validation, defaults, and operation dependencies.
3
+ * Optional service members do not relax a declared capability's required method set.
4
+ */
5
+ type JsonPrimitive = string | number | boolean | null;
6
+ type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
7
+
8
+ /** Deeply frozen per-call Host metadata. Globals are disposable; origin is normalized HTTPS. */
9
+ interface PluginInvocationContextV1 {
10
+ readonly pluginId: string;
11
+ readonly platformId: string;
12
+ readonly packageHash: string;
13
+ readonly scope: "detector" | "login" | "account" | "guest";
14
+ readonly origin: string;
15
+ /** Present for account calls; login and guest calls do not expose an account credential. */
16
+ readonly accountId?: string;
17
+ readonly locale: string;
18
+ /** HTTP allowlist for this scope; authOrigins expands login only, not account/guest access. */
19
+ readonly approvedOrigins: readonly string[];
20
+ readonly credentialAvailable: boolean;
21
+ readonly assetHandles: readonly string[];
22
+ }
23
+
24
+ /** Register once, synchronously. Run Host API calls only inside these methods.
25
+ * Manifest operation names, registration paths, and login negotiation must agree.
26
+ * Await or return all work whose failure must fail the invocation. The Host drains
27
+ * started Host tasks, but detached Promise rejections do not fail the returned chain.
28
+ * Targets use JavaScriptCore (native Apple), AndroidX JavaScriptEngine (Android),
29
+ * or QuickJS (Compose Desktop). No uniform heap/stack cap or busy-loop interruption.
30
+ * @see references/capabilities.md
31
+ */
32
+ interface PluginDefinitionV1 {
33
+ detector?: { detect(request: DetectorRequestV1, context: PluginInvocationContextV1): MaybePromise<DetectorResultV1> };
34
+ login?: Record<string, LoginServiceV1>;
35
+ capabilities?: {
36
+ timeline?: { page(request: TimelinePageRequestV1, context: PluginInvocationContextV1): MaybePromise<PageV1<PostV1>> };
37
+ search?: Partial<SearchServiceV1>;
38
+ profile?: Partial<ProfileServiceV1>;
39
+ post?: Partial<PostServiceV1>;
40
+ relation?: Partial<RelationServiceV1>;
41
+ compose?: { publish(request: ComposeRequestV1, context: PluginInvocationContextV1): MaybePromise<ComposeResultV1> };
42
+ notification?: Partial<NotificationServiceV1>;
43
+ list?: Partial<ListServiceV1>;
44
+ directMessage?: Partial<DirectMessageServiceV1>;
45
+ article?: Partial<ArticleServiceV1>;
46
+ gallery?: Partial<GalleryServiceV1>;
47
+ tabCatalog?: { page(request: PageRequestV1, context: PluginInvocationContextV1): MaybePromise<PageV1<TimelineSectionV1>> };
48
+ };
49
+ }
50
+
51
+ /** Key this service by the manifest login method ID.
52
+ * OAuth: begin -> externalBrowser/success, resume -> success.
53
+ * Password/CredentialImport/Form: begin -> success.
54
+ * WebCookie: begin -> webCookie, check -> pending/success.
55
+ * @see references/authentication.md
56
+ */
57
+ interface LoginServiceV1 {
58
+ begin(request: LoginBeginRequestV1, context: PluginInvocationContextV1): MaybePromise<LoginTransitionV1>;
59
+ resume?(request: LoginResumeRequestV1, context: PluginInvocationContextV1): MaybePromise<LoginTransitionV1>;
60
+ check?(request: CookieCheckRequestV1, context: PluginInvocationContextV1): MaybePromise<LoginTransitionV1>;
61
+ }
62
+
63
+ interface SearchServiceV1 {
64
+ posts(request: SearchRequestV1, context: PluginInvocationContextV1): MaybePromise<PageV1<PostV1>>;
65
+ profiles(request: SearchRequestV1, context: PluginInvocationContextV1): MaybePromise<PageV1<ProfileV1>>;
66
+ discoverPosts(request: SearchRequestV1, context: PluginInvocationContextV1): MaybePromise<PageV1<PostV1>>;
67
+ discoverProfiles(request: SearchRequestV1, context: PluginInvocationContextV1): MaybePromise<PageV1<ProfileV1>>;
68
+ discoverHashtags(request: SearchRequestV1, context: PluginInvocationContextV1): MaybePromise<PageV1<HashtagV1>>;
69
+ }
70
+
71
+ interface ProfileServiceV1 {
72
+ byId(request: EntityRequestV1, context: PluginInvocationContextV1): MaybePromise<ProfileV1>;
73
+ byHandle(request: HandleRequestV1, context: PluginInvocationContextV1): MaybePromise<ProfileV1>;
74
+ timeline(request: ProfileTimelineRequestV1, context: PluginInvocationContextV1): MaybePromise<PageV1<PostV1>>;
75
+ following(request: EntityPageRequestV1, context: PluginInvocationContextV1): MaybePromise<PageV1<ProfileV1>>;
76
+ followers(request: EntityPageRequestV1, context: PluginInvocationContextV1): MaybePromise<PageV1<ProfileV1>>;
77
+ }
78
+
79
+ interface PostServiceV1 {
80
+ detail(request: EntityRequestV1, context: PluginInvocationContextV1): MaybePromise<PostV1>;
81
+ context(request: EntityPageRequestV1, context: PluginInvocationContextV1): MaybePromise<PageV1<PostV1>>;
82
+ delete(request: EntityRequestV1, context: PluginInvocationContextV1): MaybePromise<DeleteResultV1>;
83
+ mutate(request: MutationRequestV1, context: PluginInvocationContextV1): MaybePromise<PostMutationResultV1>;
84
+ }
85
+
86
+ interface RelationServiceV1 {
87
+ state(request: EntityRequestV1, context: PluginInvocationContextV1): MaybePromise<RelationV1>;
88
+ mutate(request: MutationRequestV1, context: PluginInvocationContextV1): MaybePromise<RelationMutationResultV1>;
89
+ }
90
+ interface NotificationServiceV1 {
91
+ page(request: NotificationPageRequestV1, context: PluginInvocationContextV1): MaybePromise<PageV1<NotificationV1>>;
92
+ badge(request: EmptyRequestV1, context: PluginInvocationContextV1): MaybePromise<CountResultV1>;
93
+ }
94
+ interface ListServiceV1 {
95
+ page(request: PageRequestV1, context: PluginInvocationContextV1): MaybePromise<PageV1<SocialListV1>>;
96
+ detail(request: EntityRequestV1, context: PluginInvocationContextV1): MaybePromise<SocialListV1>;
97
+ create(request: ListMutationRequestV1, context: PluginInvocationContextV1): MaybePromise<SocialListV1>;
98
+ update(request: ListMutationRequestV1, context: PluginInvocationContextV1): MaybePromise<SocialListV1>;
99
+ delete(request: ListMutationRequestV1, context: PluginInvocationContextV1): MaybePromise<DeleteResultV1>;
100
+ timeline(request: EntityPageRequestV1, context: PluginInvocationContextV1): MaybePromise<PageV1<PostV1>>;
101
+ members(request: EntityPageRequestV1, context: PluginInvocationContextV1): MaybePromise<PageV1<ProfileV1>>;
102
+ memberships(request: EntityPageRequestV1, context: PluginInvocationContextV1): MaybePromise<PageV1<SocialListV1>>;
103
+ addMember(request: ListMemberRequestV1, context: PluginInvocationContextV1): MaybePromise<ProfileV1>;
104
+ removeMember(request: ListMemberRequestV1, context: PluginInvocationContextV1): MaybePromise<DeleteResultV1>;
105
+ }
106
+ interface DirectMessageServiceV1 {
107
+ rooms(request: PageRequestV1, context: PluginInvocationContextV1): MaybePromise<PageV1<DirectMessageRoomV1>>;
108
+ room(request: EntityRequestV1, context: PluginInvocationContextV1): MaybePromise<DirectMessageRoomV1>;
109
+ messages(request: DirectMessagePageRequestV1, context: PluginInvocationContextV1): MaybePromise<PageV1<DirectMessageV1>>;
110
+ send(request: DirectMessageSendRequestV1, context: PluginInvocationContextV1): MaybePromise<DirectMessageV1>;
111
+ delete(request: DirectMessageDeleteRequestV1, context: PluginInvocationContextV1): MaybePromise<DeleteResultV1>;
112
+ leave(request: EntityRequestV1, context: PluginInvocationContextV1): MaybePromise<DeleteResultV1>;
113
+ create(request: EntityRequestV1, context: PluginInvocationContextV1): MaybePromise<DirectMessageRoomV1>;
114
+ badge(request: EmptyRequestV1, context: PluginInvocationContextV1): MaybePromise<CountResultV1>;
115
+ canSend(request: EntityRequestV1, context: PluginInvocationContextV1): MaybePromise<BooleanResultV1>;
116
+ }
117
+ interface ArticleServiceV1 {
118
+ detail(request: EntityRequestV1, context: PluginInvocationContextV1): MaybePromise<ArticleV1>;
119
+ comments(request: EntityPageRequestV1, context: PluginInvocationContextV1): MaybePromise<PageV1<PostV1>>;
120
+ }
121
+ interface GalleryServiceV1 {
122
+ detail(request: EntityRequestV1, context: PluginInvocationContextV1): MaybePromise<GalleryV1>;
123
+ comments(request: EntityPageRequestV1, context: PluginInvocationContextV1): MaybePromise<PageV1<PostV1>>;
124
+ recommendations(request: EntityPageRequestV1, context: PluginInvocationContextV1): MaybePromise<PageV1<PostV1>>;
125
+ }
126
+
127
+ interface DetectorRequestV1 { origin: string }
128
+ type DetectorMatchV1 = "none" | "compatible" | "exact";
129
+ /** Return none unless the website matches verified software/API evidence. */
130
+ interface DetectorResultV1 {
131
+ match: DetectorMatchV1;
132
+ canonicalOrigin: string;
133
+ software?: string;
134
+ instance?: InstanceMetadataV1;
135
+ }
136
+ interface InstanceMetadataV1 {
137
+ domain: string;
138
+ title?: string;
139
+ description?: string;
140
+ iconUrl?: string;
141
+ bannerUrl?: string;
142
+ usersCount?: number;
143
+ registrationEnabled?: boolean;
144
+ }
145
+
146
+ interface LoginBeginRequestV1 {
147
+ methodId: string;
148
+ origin: string;
149
+ flowId: string;
150
+ state?: string;
151
+ redirectUri?: string;
152
+ values: Record<string, string>;
153
+ }
154
+ interface LoginResumeRequestV1 {
155
+ methodId: string;
156
+ origin: string;
157
+ flowId: string;
158
+ redirectUri?: string;
159
+ callbackParameters: Record<string, string>;
160
+ /** Host-persisted OAuth data from begin; globals do not survive the flow. At most 64 KiB JSON. */
161
+ pendingPayload?: JsonValue;
162
+ }
163
+ interface CookieCheckRequestV1 {
164
+ methodId: string;
165
+ origin: string;
166
+ flowId: string;
167
+ cookies: { cookies: CookieValueV1[] };
168
+ }
169
+ /** sourceUrl is the declared probe URL; do not merge Cookies across probes/origins blindly. */
170
+ interface CookieValueV1 { sourceUrl: string; name: string; value: string }
171
+ type LoginTransitionV1 =
172
+ | { type: "pending" }
173
+ | { type: "externalBrowser"; url: string; pendingPayload: JsonValue }
174
+ | { type: "webCookie"; startUrl: string }
175
+ | { type: "success"; value: LoginSuccessV1 };
176
+ /** accountId must equal profile.key.id; origin/profile host must match the selected instance.
177
+ * Negotiate declared complete capability sets with at least one displayable capability.
178
+ * The Host persists credential (at most 64 KiB JSON) and the authenticated profile.
179
+ */
180
+ interface LoginSuccessV1 {
181
+ accountId: string;
182
+ origin: string;
183
+ credential: JsonValue;
184
+ profile: ProfileV1;
185
+ capabilities: Record<string, string[]>;
186
+ composeConfig?: ComposeConfigV1;
187
+ }
188
+
189
+ /** Stable ID in the selected API namespace; host is an HTTPS authority without scheme/path. */
190
+ interface EntityKeyV1 { id: string; host: string }
191
+ /** format defaults to plain. HTML is sanitized; remote HTML images also need mediaNetwork. */
192
+ interface RichTextV1 { format?: "plain" | "html"; value: string }
193
+ interface ProfileFieldV1 { name: string; value: RichTextV1 }
194
+ interface ProfileV1 {
195
+ key: EntityKeyV1;
196
+ handle: string;
197
+ displayName: string;
198
+ avatarUrl?: string;
199
+ bannerUrl?: string;
200
+ description?: RichTextV1;
201
+ url?: string;
202
+ followersCount?: number;
203
+ followingCount?: number;
204
+ postsCount?: number;
205
+ locked?: boolean;
206
+ bot?: boolean;
207
+ fields?: ProfileFieldV1[];
208
+ entityToken?: string;
209
+ actions?: ActionDescriptorV1[];
210
+ }
211
+ type MediaTypeV1 = "image" | "video" | "gif" | "audio";
212
+ /** HTTPS URLs only; automatic loading requires permissions.mediaNetwork. Preserve alt text. */
213
+ interface MediaV1 {
214
+ id: string;
215
+ type: MediaTypeV1;
216
+ url: string;
217
+ previewUrl?: string;
218
+ description?: string;
219
+ width?: number;
220
+ height?: number;
221
+ durationMillis?: number;
222
+ }
223
+ type VisibilityV1 = "public" | "unlisted" | "followers" | "direct";
224
+ type SemanticActionV1 =
225
+ | "Favourite" | "Unfavourite" | "Repost" | "Unrepost" | "Bookmark" | "Unbookmark"
226
+ | "Delete" | "Reply" | "Follow" | "Unfollow" | "Block" | "Unblock" | "Mute" | "Unmute";
227
+ /** Semantic Host control. enabled defaults true; active/count remain meaningful when disabled.
228
+ * actionToken is bounded opaque mutation data, not a page cursor or credential store.
229
+ */
230
+ interface ActionDescriptorV1 {
231
+ action: SemanticActionV1;
232
+ enabled?: boolean;
233
+ active?: boolean;
234
+ count?: number;
235
+ actionToken?: string;
236
+ }
237
+ type PostDetailTypeV1 = "post" | "article" | "gallery" | "link";
238
+ interface PostV1 {
239
+ key: EntityKeyV1;
240
+ /** Defaults to null. When present, the complete ProfileV1 contract still applies. */
241
+ author?: ProfileV1 | null;
242
+ createdAt: string;
243
+ content: RichTextV1;
244
+ /** Required for detailType "link"; absolute HTTPS URL without URL credentials. */
245
+ url?: string;
246
+ media?: MediaV1[];
247
+ repost?: PostV1;
248
+ replyTo?: EntityKeyV1;
249
+ spoilerText?: string;
250
+ sensitive?: boolean;
251
+ /** Defaults to null; omitted visibility is not inferred as public. */
252
+ visibility?: VisibilityV1 | null;
253
+ favouritesCount?: number;
254
+ repostsCount?: number;
255
+ repliesCount?: number;
256
+ entityToken?: string;
257
+ actions?: ActionDescriptorV1[];
258
+ /** Defaults to "post"; internal details use key, while "link" opens url in the browser. */
259
+ detailType?: PostDetailTypeV1;
260
+ }
261
+ interface HashtagV1 { name: string; url?: string }
262
+
263
+ type PageDirectionV1 = "refresh" | "older" | "newer";
264
+ /** limit is 1..100. refresh starts without a continuation; older/newer use their own cursors. */
265
+ interface PageRequestV1 {
266
+ direction: PageDirectionV1;
267
+ limit: number;
268
+ cursor?: string;
269
+ parameters: Record<string, string>;
270
+ }
271
+ /** items defaults []; endReached defaults false and suppresses olderCursor only.
272
+ * Empty pages may still advance cursors. At most 200 items; cursors at most 4096 characters.
273
+ * @see references/wire-data.md
274
+ */
275
+ interface PageV1<T> {
276
+ items?: T[];
277
+ olderCursor?: string;
278
+ newerCursor?: string;
279
+ endReached?: boolean;
280
+ }
281
+ interface TimelinePageRequestV1 { timelineId: string; page: PageRequestV1; parameters: Record<string, string> }
282
+ interface SearchRequestV1 { query: string; page: PageRequestV1 }
283
+ /** entityToken is opaque entity context (at most 8192 characters), not a paging cursor. */
284
+ interface EntityRequestV1 { key: EntityKeyV1; entityToken?: string }
285
+ interface EntityPageRequestV1 { key: EntityKeyV1; page: PageRequestV1; entityToken?: string }
286
+ interface HandleRequestV1 { handle: string; host: string }
287
+ interface ProfileTimelineRequestV1 {
288
+ profile: EntityKeyV1;
289
+ tabId?: string;
290
+ page: PageRequestV1;
291
+ parameters: Record<string, string>;
292
+ }
293
+ interface MutationRequestV1 {
294
+ key: EntityKeyV1;
295
+ action: SemanticActionV1;
296
+ actionToken?: string;
297
+ parameters: Record<string, string>;
298
+ }
299
+ /** Each operation accepts only its narrowed result union below. Remote failure should throw.
300
+ * Updated entities must match the requested key; invalidation keys must be unique.
301
+ */
302
+ type MutationResultV1 =
303
+ | { type: "updatedPost"; post: PostV1 }
304
+ | { type: "updatedProfile"; profile: ProfileV1 }
305
+ | { type: "updatedRelation"; relation: RelationV1 }
306
+ | { type: "deleted" }
307
+ | { type: "invalidate"; keys: EntityKeyV1[] }
308
+ | { type: "noChange" };
309
+ type DeleteResultV1 = Extract<MutationResultV1, { type: "deleted" | "noChange" }>;
310
+ type PostMutationResultV1 = Extract<MutationResultV1, { type: "updatedPost" | "deleted" | "invalidate" | "noChange" }>;
311
+ type RelationMutationResultV1 = Extract<MutationResultV1, { type: "updatedRelation" | "noChange" }>;
312
+
313
+ /** Return full authoritative relation state after mutation, including replacement actionTokens.
314
+ * Omitted booleans default false; partial updatedRelation can clear existing state.
315
+ */
316
+ interface RelationV1 {
317
+ profileKey: EntityKeyV1;
318
+ following?: boolean;
319
+ followedBy?: boolean;
320
+ blocking?: boolean;
321
+ muting?: boolean;
322
+ actionTokens?: Partial<Record<SemanticActionV1, string>>;
323
+ }
324
+ type NotificationKindV1 = "Mention" | "Reply" | "Favourite" | "Repost" | "Follow" | "Other";
325
+ /** id identifies the event, not its post; multiple events on one post remain distinct. */
326
+ interface NotificationV1 {
327
+ id: string;
328
+ createdAt: string;
329
+ kind: NotificationKindV1;
330
+ actor?: ProfileV1;
331
+ post?: PostV1;
332
+ message?: RichTextV1;
333
+ }
334
+ interface SocialListV1 { id: string; title: string; memberCount?: number; entityToken?: string }
335
+ interface DirectMessageRoomV1 {
336
+ key: EntityKeyV1;
337
+ title: string;
338
+ participants: ProfileV1[];
339
+ lastMessage?: DirectMessageV1;
340
+ unreadCount?: number;
341
+ entityToken?: string;
342
+ }
343
+ interface DirectMessageV1 {
344
+ key: EntityKeyV1;
345
+ roomKey: EntityKeyV1;
346
+ sender: ProfileV1;
347
+ createdAt: string;
348
+ content: RichTextV1;
349
+ fromCurrentAccount?: boolean;
350
+ entityToken?: string;
351
+ }
352
+ interface ArticleV1 {
353
+ key: EntityKeyV1;
354
+ title: string;
355
+ author?: ProfileV1;
356
+ createdAt?: string;
357
+ content: RichTextV1;
358
+ url?: string;
359
+ coverUrl?: string;
360
+ }
361
+ interface GalleryV1 {
362
+ key: EntityKeyV1;
363
+ title: string;
364
+ author?: ProfileV1;
365
+ createdAt: string;
366
+ content?: RichTextV1;
367
+ url: string;
368
+ images: MediaV1[];
369
+ orientation?: "horizontal" | "vertical";
370
+ entityToken?: string;
371
+ actions?: ActionDescriptorV1[];
372
+ }
373
+ type HostIconV1 =
374
+ | "Home" | "Notification" | "Search" | "Profile" | "Local" | "World" | "Featured"
375
+ | "Bookmark" | "Heart" | "List" | "Messages" | "Channel" | "Like" | "Repost"
376
+ | "Reply" | "Delete" | "Follow" | "Block" | "Mute" | "Info";
377
+ interface TimelineDescriptorV1 {
378
+ id: string;
379
+ title: WireTextV1;
380
+ icon: HostIconV1;
381
+ display?: "List" | "Grid";
382
+ parameters?: Record<string, string>;
383
+ }
384
+ interface TimelineSectionV1 { id: string; title: WireTextV1; timelines: PageV1<TimelineDescriptorV1> }
385
+ type EmptyRequestV1 = Record<string, never>;
386
+ interface NotificationPageRequestV1 { filterId?: string; page: PageRequestV1 }
387
+ interface ListMutationRequestV1 { id?: string; title?: string; entityToken?: string }
388
+ interface ListMemberRequestV1 { listId: string; profileKey: EntityKeyV1; entityToken?: string }
389
+ interface DirectMessageSendRequestV1 { roomKey: EntityKeyV1; text: string; roomEntityToken?: string }
390
+ /** Poll with page.newerCursor from the previous result, not roomEntityToken. */
391
+ interface DirectMessagePageRequestV1 { roomKey: EntityKeyV1; page: PageRequestV1; roomEntityToken?: string }
392
+ interface DirectMessageDeleteRequestV1 {
393
+ roomKey: EntityKeyV1;
394
+ messageKey: EntityKeyV1;
395
+ roomEntityToken?: string;
396
+ messageEntityToken?: string;
397
+ }
398
+ interface CountResultV1 { value: number }
399
+ interface BooleanResultV1 { value: boolean }
400
+
401
+ /** Manifest defaults are required for compose.publish. Login config is merged by section:
402
+ * shared limits are narrowed; a section present on only one side is retained.
403
+ * @see references/manifest.md#compose-defaults
404
+ */
405
+ interface ComposeConfigV1 {
406
+ text?: { maxLength: number };
407
+ media?: {
408
+ minCountForNew?: number;
409
+ maxCount: number;
410
+ maxBytes: number;
411
+ supportedMimeTypes?: string[];
412
+ altTextMaxLength?: number;
413
+ canSensitive?: boolean;
414
+ };
415
+ visibility?: { allowed: VisibilityV1[]; default?: VisibilityV1 };
416
+ contentWarning?: boolean;
417
+ poll?: { maxOptions: number };
418
+ language?: { maxCount: number };
419
+ }
420
+ /** Host-owned per-invocation upload handle. Forward via multipart; bytes never enter JS. */
421
+ interface ComposeAssetV1 { handle: string; fileName?: string; mimeType?: string; description?: string }
422
+ interface ComposePollV1 { options: string[]; expiresInSeconds: number; multiple?: boolean }
423
+ interface ComposeRequestV1 {
424
+ text: string;
425
+ visibility: VisibilityV1;
426
+ languages: string[];
427
+ assets: ComposeAssetV1[];
428
+ sensitive: boolean;
429
+ spoilerText?: string;
430
+ replyTo?: EntityKeyV1;
431
+ poll?: ComposePollV1;
432
+ }
433
+ interface ComposeResultV1 { post: PostV1 }
434
+
435
+ type HttpAuthorizationV1 =
436
+ | { type: "bearer"; token: string }
437
+ | { type: "basic"; username: string; password: string };
438
+ type HttpBodyV1 =
439
+ | { type: "json"; value: JsonValue }
440
+ | { type: "text"; value: string; contentType?: string }
441
+ | { type: "form"; values: Record<string, string> }
442
+ | { type: "multipart"; parts: HttpMultipartPartV1[] };
443
+ type HttpMultipartPartV1 =
444
+ | { type: "text"; name: string; value: string; contentType?: string }
445
+ | { type: "asset"; name: string; handle: string; fileName?: string; contentType?: string };
446
+ /** Absolute approved HTTPS URL. method defaults GET; GET/HEAD cannot have a body.
447
+ * Use structured authorization/cookies; raw transport/auth headers are restricted.
448
+ * timeoutMillis may not exceed the current invocation deadline.
449
+ */
450
+ interface HttpRequestV1 {
451
+ method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD";
452
+ url: string;
453
+ headers?: Record<string, string>;
454
+ authorization?: HttpAuthorizationV1;
455
+ cookies?: { name: string; value: string }[];
456
+ body?: HttpBodyV1;
457
+ timeoutMillis?: number;
458
+ }
459
+ /** body is text; parse JSON explicitly after checking status. Headers contain string arrays.
460
+ * HTTP failure statuses are returned normally; this is not a browser Response.
461
+ */
462
+ interface HttpResponseV1 { status: number; headers: Record<string, string[]>; body: string }
463
+
464
+ type PluginErrorCodeV1 =
465
+ | "AuthenticationRequired" | "NotFound" | "Validation" | "RateLimited" | "Network"
466
+ | "Remote" | "Unsupported" | "InvalidResponse" | "Cancelled";
467
+ /** Exactly one of value/key; localized key requires fallback.
468
+ * args accepts finite numbers, booleans, or strings; omit null arguments.
469
+ * Manifest text instead uses a string or { key, fallback }.
470
+ */
471
+ interface WireTextV1 { value?: string; key?: string; fallback?: string; args?: Record<string, JsonPrimitive> }
472
+ /** Throw flare.error(value) for recoverable failures; retryAfterSeconds is 0..604800. */
473
+ interface PluginErrorV1 {
474
+ code: PluginErrorCodeV1;
475
+ message: WireTextV1;
476
+ retryAfterSeconds?: number;
477
+ remoteCode?: string;
478
+ }
479
+
480
+ interface FlareHostV1 {
481
+ readonly http: { request(request: HttpRequestV1): Promise<HttpResponseV1> };
482
+ /** Available only in account scope. replace atomically replaces the whole JSON payload. */
483
+ readonly credential: {
484
+ read(): Promise<JsonValue>;
485
+ replace(value: JsonValue): Promise<boolean>;
486
+ };
487
+ readonly crypto: {
488
+ /** size counts random bytes (1..1024), producing twice as many hex characters. */
489
+ randomHex(size: number): Promise<string>;
490
+ uuid(): Promise<string>;
491
+ /** SHA-256 of UTF-8 input (at most 1 MiB); returns hex, not base64url/bytes. */
492
+ sha256(value: string): Promise<string>;
493
+ };
494
+ readonly locale: { current(): Promise<string> };
495
+ error(value: PluginErrorV1): Error;
496
+ }
497
+
498
+ type MaybePromise<T> = T | Promise<T>;
499
+
500
+ declare function definePlugin(plugin: PluginDefinitionV1): void;
501
+ declare const flare: FlareHostV1;
@@ -0,0 +1,155 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { lstat, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
4
+ import { dirname, join, relative, resolve, sep } from "node:path";
5
+ import process from "node:process";
6
+
7
+ const MAX_FILES = 256;
8
+ const MAX_TOTAL_BYTES = 50 * 1024 * 1024;
9
+ const ALLOWED_PATH = /^(?:manifest\.json|plugin\.js|assets\/icon\.(?:png|jpe?g|webp|gif|ico)|locales\/[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*\.json)$/;
10
+ const REQUIRED_PATHS = new Set(["manifest.json", "plugin.js"]);
11
+
12
+ async function main() {
13
+ const [inputArgument, outputArgument] = process.argv.slice(2);
14
+ if (!inputArgument || process.argv.length > 4) {
15
+ throw new Error("Usage: node pack.mjs <plugin-directory> [output.fpp]");
16
+ }
17
+
18
+ const input = resolve(inputArgument);
19
+ const rootMetadata = await lstat(input);
20
+ if (!rootMetadata.isDirectory() || rootMetadata.isSymbolicLink()) {
21
+ throw new Error("Plugin source must be a real directory");
22
+ }
23
+
24
+ const files = await collectFiles(input);
25
+ const names = files.map((file) => file.name);
26
+ for (const required of REQUIRED_PATHS) {
27
+ if (!names.includes(required)) throw new Error(`Missing required file: ${required}`);
28
+ }
29
+
30
+ if (names.filter((name) => name.startsWith("assets/icon.")).length !== 1) {
31
+ throw new Error("Package must contain exactly one PNG, JPEG, WebP, GIF or ICO icon");
32
+ }
33
+
34
+ const manifest = JSON.parse((await readFile(join(input, "manifest.json"))).toString("utf8"));
35
+ const packageName = `${manifest.id ?? "plugin"}-${manifest.version ?? "0.0.0"}.fpp`;
36
+ const output = resolve(outputArgument ?? packageName);
37
+ if (!output.endsWith(".fpp")) throw new Error("Output file must use the .fpp extension");
38
+
39
+ const archive = createZip(files);
40
+ if (archive.length > 20 * 1024 * 1024) throw new Error("Package exceeds 20 MiB");
41
+ await mkdir(dirname(output), { recursive: true });
42
+ await writeFile(output, archive);
43
+ process.stdout.write(`${output}\n`);
44
+ }
45
+
46
+ async function collectFiles(root) {
47
+ const result = [];
48
+
49
+ async function visit(directory) {
50
+ const entries = await readdir(directory, { withFileTypes: true });
51
+ entries.sort((left, right) => left.name.localeCompare(right.name, "en"));
52
+ for (const entry of entries) {
53
+ const absolute = join(directory, entry.name);
54
+ const metadata = await lstat(absolute);
55
+ if (metadata.isSymbolicLink()) throw new Error(`Symbolic links are not supported: ${entry.name}`);
56
+ if (metadata.isDirectory()) {
57
+ await visit(absolute);
58
+ continue;
59
+ }
60
+ if (!metadata.isFile()) throw new Error(`Unsupported filesystem entry: ${entry.name}`);
61
+ const name = relative(root, absolute).split(sep).join("/");
62
+ if (name === "README.md") continue;
63
+ if (!ALLOWED_PATH.test(name)) throw new Error(`Unsupported package path: ${name}`);
64
+ if (![...name].every((character) => character.codePointAt(0) >= 0x20 && character.codePointAt(0) <= 0x7e)) {
65
+ throw new Error(`Package paths must be ASCII: ${name}`);
66
+ }
67
+ result.push({ name, data: await readFile(absolute) });
68
+ }
69
+ }
70
+
71
+ await visit(root);
72
+ result.sort((left, right) => left.name.localeCompare(right.name, "en"));
73
+ if (result.length === 0 || result.length > MAX_FILES) throw new Error("Invalid package file count");
74
+ if (result.reduce((total, file) => total + file.data.length, 0) > MAX_TOTAL_BYTES) {
75
+ throw new Error("Package exceeds 50 MiB uncompressed");
76
+ }
77
+ const folded = result.map((file) => file.name.toLowerCase());
78
+ if (new Set(folded).size !== folded.length) throw new Error("Duplicate or case-conflicting package path");
79
+ return result;
80
+ }
81
+
82
+ function createZip(files) {
83
+ const localParts = [];
84
+ const centralParts = [];
85
+ let localOffset = 0;
86
+
87
+ for (const file of files) {
88
+ const name = Buffer.from(file.name, "ascii");
89
+ const checksum = crc32(file.data);
90
+ const local = Buffer.alloc(30);
91
+ local.writeUInt32LE(0x04034b50, 0);
92
+ local.writeUInt16LE(20, 4);
93
+ local.writeUInt16LE(0x0800, 6);
94
+ local.writeUInt16LE(0, 8);
95
+ local.writeUInt16LE(0, 10);
96
+ local.writeUInt16LE(33, 12);
97
+ local.writeUInt32LE(checksum, 14);
98
+ local.writeUInt32LE(file.data.length, 18);
99
+ local.writeUInt32LE(file.data.length, 22);
100
+ local.writeUInt16LE(name.length, 26);
101
+ local.writeUInt16LE(0, 28);
102
+ localParts.push(local, name, file.data);
103
+
104
+ const central = Buffer.alloc(46);
105
+ central.writeUInt32LE(0x02014b50, 0);
106
+ central.writeUInt16LE(20, 4);
107
+ central.writeUInt16LE(20, 6);
108
+ central.writeUInt16LE(0x0800, 8);
109
+ central.writeUInt16LE(0, 10);
110
+ central.writeUInt16LE(0, 12);
111
+ central.writeUInt16LE(33, 14);
112
+ central.writeUInt32LE(checksum, 16);
113
+ central.writeUInt32LE(file.data.length, 20);
114
+ central.writeUInt32LE(file.data.length, 24);
115
+ central.writeUInt16LE(name.length, 28);
116
+ central.writeUInt16LE(0, 30);
117
+ central.writeUInt16LE(0, 32);
118
+ central.writeUInt16LE(0, 34);
119
+ central.writeUInt16LE(0, 36);
120
+ central.writeUInt32LE(0, 38);
121
+ central.writeUInt32LE(localOffset, 42);
122
+ centralParts.push(central, name);
123
+
124
+ localOffset += local.length + name.length + file.data.length;
125
+ }
126
+
127
+ const centralDirectory = Buffer.concat(centralParts);
128
+ const end = Buffer.alloc(22);
129
+ end.writeUInt32LE(0x06054b50, 0);
130
+ end.writeUInt16LE(0, 4);
131
+ end.writeUInt16LE(0, 6);
132
+ end.writeUInt16LE(files.length, 8);
133
+ end.writeUInt16LE(files.length, 10);
134
+ end.writeUInt32LE(centralDirectory.length, 12);
135
+ end.writeUInt32LE(localOffset, 16);
136
+ end.writeUInt16LE(0, 20);
137
+ return Buffer.concat([...localParts, centralDirectory, end]);
138
+ }
139
+
140
+ function crc32(bytes) {
141
+ let value = 0xffffffff;
142
+ for (const byte of bytes) value = CRC_TABLE[(value ^ byte) & 0xff] ^ (value >>> 8);
143
+ return (value ^ 0xffffffff) >>> 0;
144
+ }
145
+
146
+ const CRC_TABLE = Array.from({ length: 256 }, (_, index) => {
147
+ let value = index;
148
+ for (let bit = 0; bit < 8; bit += 1) value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
149
+ return value >>> 0;
150
+ });
151
+
152
+ main().catch((error) => {
153
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
154
+ process.exitCode = 1;
155
+ });