@pyric/ui 0.1.0-alpha.11 → 0.1.0-alpha.13
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/package.json +5 -4
- package/src/agents/ContextWindowUsage.tsx +514 -0
- package/src/agents/EmptyState.tsx +27 -0
- package/src/agents/Fold.tsx +64 -0
- package/src/agents/Modal.tsx +65 -0
- package/src/agents/PulsingDot.tsx +28 -0
- package/src/agents/inbrowser-agent-usage.d.ts +141 -0
- package/src/agents/index.ts +28 -0
- package/src/auth/authApi.ts +72 -0
- package/src/auth/claims.ts +63 -0
- package/src/auth/components/AuthProviderToggles.tsx +147 -0
- package/src/auth/components/AuthSignInHelper.tsx +197 -0
- package/src/auth/components/AuthUserForm.tsx +328 -0
- package/src/auth/components/AuthUserList.tsx +219 -0
- package/src/auth/components/ClaimsField.tsx +50 -0
- package/src/auth/components/confirmActions.tsx +114 -0
- package/src/auth/controller.ts +173 -0
- package/src/auth/hooks/index.ts +36 -0
- package/src/auth/hooks/useAuthFlowHelper.ts +55 -0
- package/src/auth/hooks/useAuthProviderConfig.ts +139 -0
- package/src/auth/hooks/useAuthUserEditor.ts +76 -0
- package/src/auth/hooks/useAuthUsers.ts +154 -0
- package/src/auth/index.ts +42 -0
- package/src/auth/providers.ts +28 -0
- package/src/auth/reducers/userEditor.ts +186 -0
- package/src/events/components/ActivityActionItems.tsx +136 -0
- package/src/events/components/ActivityGrid.tsx +197 -0
- package/src/events/components/ActivityGridRow.tsx +65 -0
- package/src/events/components/ProposedChangeDiff.tsx +175 -0
- package/src/events/components/format.ts +21 -0
- package/src/events/components/index.ts +17 -0
- package/src/events/digest.ts +630 -0
- package/src/events/hooks/index.ts +9 -0
- package/src/events/hooks/useActivityDigest.ts +42 -0
- package/src/events/hooks/useActivityStream.ts +86 -0
- package/src/events/index.ts +39 -0
- package/src/events/types.ts +152 -0
- package/src/firestore/components/CollectionList.tsx +78 -0
- package/src/firestore/components/DeleteWithConfirm.tsx +88 -0
- package/src/firestore/components/DocumentEditor.tsx +350 -0
- package/src/firestore/components/DocumentList.tsx +217 -0
- package/src/firestore/components/DocumentPreview.tsx +265 -0
- package/src/firestore/components/FieldRenderer.tsx +25 -0
- package/src/firestore/components/QueryBuilder.tsx +181 -0
- package/src/firestore/components/ReferencePicker.tsx +212 -0
- package/src/firestore/components/TreeEntry.tsx +58 -0
- package/src/firestore/components/context.ts +25 -0
- package/src/firestore/fieldEditors/array.tsx +30 -0
- package/src/firestore/fieldEditors/boolean.tsx +39 -0
- package/src/firestore/fieldEditors/bytes.tsx +53 -0
- package/src/firestore/fieldEditors/geopoint.tsx +71 -0
- package/src/firestore/fieldEditors/map.tsx +33 -0
- package/src/firestore/fieldEditors/null.tsx +27 -0
- package/src/firestore/fieldEditors/number.tsx +43 -0
- package/src/firestore/fieldEditors/reference.tsx +87 -0
- package/src/firestore/fieldEditors/registry.ts +45 -0
- package/src/firestore/fieldEditors/string.tsx +33 -0
- package/src/firestore/fieldEditors/timestamp.tsx +75 -0
- package/src/firestore/fieldEditors/types.ts +68 -0
- package/src/firestore/fieldEditors/vector.tsx +142 -0
- package/src/firestore/firestoreApi.ts +86 -0
- package/src/firestore/hooks/coerceError.ts +34 -0
- package/src/firestore/hooks/index.ts +46 -0
- package/src/firestore/hooks/useCollectionList.ts +102 -0
- package/src/firestore/hooks/useDocumentEditor.ts +161 -0
- package/src/firestore/hooks/useDocumentList.ts +242 -0
- package/src/firestore/hooks/useDocumentSubcollections.ts +86 -0
- package/src/firestore/hooks/useFirestoreCollection.ts +51 -0
- package/src/firestore/hooks/useFirestoreDoc.ts +55 -0
- package/src/firestore/hooks/useQueryBuilder.ts +188 -0
- package/src/firestore/hooks/useRecursiveDelete.ts +77 -0
- package/src/firestore/hooks/useReferencePicker.ts +228 -0
- package/src/firestore/import/parseImport.ts +137 -0
- package/src/firestore/index.ts +87 -0
- package/src/firestore/reducers/defaults.ts +38 -0
- package/src/firestore/reducers/documentEditor.ts +239 -0
- package/src/firestore/reducers/tree.ts +140 -0
- package/src/firestore/reducers/types.ts +92 -0
- package/src/firestore/reducers/validation.ts +129 -0
- package/src/firestore/types.ts +231 -0
- package/src/firestore/validation/ids.ts +45 -0
- package/src/firestore/valueEquality.ts +43 -0
- package/src/index.ts +10 -0
- package/src/primitives/Badge.tsx +40 -0
- package/src/primitives/ConfirmDialog.tsx +137 -0
- package/src/primitives/CopyButton.tsx +62 -0
- package/src/primitives/JsonView.tsx +151 -0
- package/src/primitives/SegmentedControl.tsx +72 -0
- package/src/primitives/Toast.tsx +161 -0
- package/src/primitives/VirtualList.tsx +104 -0
- package/src/primitives/hooks/useContainerSize.ts +53 -0
- package/src/primitives/hooks/useUpdateHighlights.ts +109 -0
- package/src/primitives/index.ts +39 -0
- package/src/primitives/useConfirm.tsx +113 -0
- package/src/rtdb/components/RtdbPathBar.tsx +135 -0
- package/src/rtdb/components/RtdbTree.tsx +409 -0
- package/src/rtdb/editor.ts +79 -0
- package/src/rtdb/hooks/useRtdbTree.ts +137 -0
- package/src/rtdb/index.ts +66 -0
- package/src/rtdb/pathInput.ts +47 -0
- package/src/rtdb/reducers/tree.ts +191 -0
- package/src/rtdb/rtdbApi.ts +23 -0
- package/src/rtdb/values.ts +188 -0
- package/src/rules/components/DenialInspector.tsx +227 -0
- package/src/rules/components/format.ts +171 -0
- package/src/rules/components/index.ts +12 -0
- package/src/rules/components/scope.ts +75 -0
- package/src/rules/hooks/index.ts +5 -0
- package/src/rules/hooks/useDenialTrace.ts +100 -0
- package/src/rules/index.ts +24 -0
- package/src/rules/types.ts +91 -0
- package/src/storage/collisionRename.ts +114 -0
- package/src/storage/components/DeleteSelectionWithConfirm.tsx +193 -0
- package/src/storage/components/ObjectBrowser.tsx +219 -0
- package/src/storage/components/ObjectInspector.tsx +169 -0
- package/src/storage/components/PathBreadcrumb.tsx +84 -0
- package/src/storage/components/UploadDropzone.tsx +182 -0
- package/src/storage/folderPlaceholder.ts +40 -0
- package/src/storage/hooks/index.ts +59 -0
- package/src/storage/hooks/useMetadataEditor.ts +329 -0
- package/src/storage/hooks/useObjectUpload.ts +262 -0
- package/src/storage/hooks/usePathState.ts +94 -0
- package/src/storage/hooks/useStorageDelete.ts +195 -0
- package/src/storage/hooks/useStorageList.ts +261 -0
- package/src/storage/hooks/useStorageObject.ts +162 -0
- package/src/storage/hooks/useStorageRulesGate.ts +270 -0
- package/src/storage/hooks/useStorageSelection.ts +90 -0
- package/src/storage/index.ts +59 -0
- package/src/storage/pendingPrefixes.ts +125 -0
- package/src/storage/previews.tsx +120 -0
- package/src/storage/storageApi.ts +54 -0
- package/src/traffic/components/RuleHeatmap.tsx +97 -0
- package/src/traffic/components/TrafficDetail.tsx +160 -0
- package/src/traffic/components/TrafficGroupRow.tsx +91 -0
- package/src/traffic/components/TrafficLineChart.tsx +139 -0
- package/src/traffic/components/TrafficLog.tsx +175 -0
- package/src/traffic/components/TrafficMetricCards.tsx +77 -0
- package/src/traffic/components/TrafficRow.tsx +69 -0
- package/src/traffic/components/TrafficStats.tsx +73 -0
- package/src/traffic/components/TrafficTimeline.tsx +289 -0
- package/src/traffic/components/format.ts +22 -0
- package/src/traffic/components/index.ts +22 -0
- package/src/traffic/hooks/index.ts +60 -0
- package/src/traffic/hooks/useRuleHeatmap.ts +92 -0
- package/src/traffic/hooks/useTrafficBuckets.ts +146 -0
- package/src/traffic/hooks/useTrafficFilter.ts +74 -0
- package/src/traffic/hooks/useTrafficGroups.ts +126 -0
- package/src/traffic/hooks/useTrafficMetrics.ts +250 -0
- package/src/traffic/hooks/useTrafficMonitor.ts +111 -0
- package/src/traffic/hooks/useTrafficStats.ts +77 -0
- package/src/traffic/index.ts +13 -0
- package/src/traffic/types.ts +85 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import type {
|
|
3
|
+
FirebaseStorage,
|
|
4
|
+
FullMetadata,
|
|
5
|
+
} from 'pyric/storage';
|
|
6
|
+
import { useStorageApi } from '../storageApi.js';
|
|
7
|
+
import { normalizeStoragePath } from './usePathState.js';
|
|
8
|
+
|
|
9
|
+
export type StorageObjectStatus = 'idle' | 'loading' | 'success' | 'error';
|
|
10
|
+
|
|
11
|
+
export interface UseStorageObjectResult {
|
|
12
|
+
/** Metadata read state. `'idle'` when `storage` or `path` is null. */
|
|
13
|
+
status: StorageObjectStatus;
|
|
14
|
+
metadata: FullMetadata | undefined;
|
|
15
|
+
/** Typed `StorageError` (`storage/object-not-found`,
|
|
16
|
+
* `storage/unauthorized`, …). */
|
|
17
|
+
error: Error | undefined;
|
|
18
|
+
/** Re-read the metadata (also resets the blob, the object may
|
|
19
|
+
* have been overwritten). */
|
|
20
|
+
refresh: () => void;
|
|
21
|
+
/** Blob read state. Stays `'idle'` until `loadBlob()`, the blob
|
|
22
|
+
* is LAZY; metadata alone never downloads bytes. */
|
|
23
|
+
blobStatus: StorageObjectStatus;
|
|
24
|
+
blob: Blob | undefined;
|
|
25
|
+
/**
|
|
26
|
+
* `URL.createObjectURL` handle for the loaded blob, used as the local
|
|
27
|
+
* preview channel. Revoked automatically when the blob is replaced,
|
|
28
|
+
* the path changes, or the hook unmounts.
|
|
29
|
+
*/
|
|
30
|
+
blobUrl: string | undefined;
|
|
31
|
+
blobError: Error | undefined;
|
|
32
|
+
/** Fetch the bytes via `getBlob`. Subsequent calls re-fetch. */
|
|
33
|
+
loadBlob: () => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface MetaState {
|
|
37
|
+
status: StorageObjectStatus;
|
|
38
|
+
metadata: FullMetadata | undefined;
|
|
39
|
+
error: Error | undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface BlobState {
|
|
43
|
+
status: StorageObjectStatus;
|
|
44
|
+
blob: Blob | undefined;
|
|
45
|
+
url: string | undefined;
|
|
46
|
+
error: Error | undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const IDLE_BLOB: BlobState = {
|
|
50
|
+
status: 'idle',
|
|
51
|
+
blob: undefined,
|
|
52
|
+
url: undefined,
|
|
53
|
+
error: undefined,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* One object's metadata + lazily-loaded bytes, the data source for
|
|
58
|
+
* `<ObjectInspector>`. Read-via-get like the rest of the storage
|
|
59
|
+
* half: updates on `refresh`, `path` change, or `loadBlob`.
|
|
60
|
+
*/
|
|
61
|
+
export function useStorageObject(
|
|
62
|
+
storage: FirebaseStorage | null | undefined,
|
|
63
|
+
path: string | null | undefined,
|
|
64
|
+
): UseStorageObjectResult {
|
|
65
|
+
// Injected: in-process `pyric/storage` by default, or the SharedWorker client
|
|
66
|
+
// bundle in Studio served mode (via StorageApiProvider).
|
|
67
|
+
const { getBlob, getMetadata, ref: refFn } = useStorageApi();
|
|
68
|
+
const normalized = path == null ? null : normalizeStoragePath(path);
|
|
69
|
+
const active = storage != null && normalized != null && normalized !== '';
|
|
70
|
+
|
|
71
|
+
const [meta, setMeta] = useState<MetaState>(() => ({
|
|
72
|
+
status: active ? 'loading' : 'idle',
|
|
73
|
+
metadata: undefined,
|
|
74
|
+
error: undefined,
|
|
75
|
+
}));
|
|
76
|
+
const [blobState, setBlobState] = useState<BlobState>(IDLE_BLOB);
|
|
77
|
+
const [tick, setTick] = useState(0);
|
|
78
|
+
// Generation token: invalidates in-flight blob loads when the
|
|
79
|
+
// path/storage changes or a newer load starts.
|
|
80
|
+
const blobGenRef = useRef(0);
|
|
81
|
+
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
// Any identity change drops the previous blob (the URL-revoking
|
|
84
|
+
// effect below cleans up the old object URL when state resets).
|
|
85
|
+
blobGenRef.current++;
|
|
86
|
+
setBlobState(IDLE_BLOB);
|
|
87
|
+
if (!active) {
|
|
88
|
+
setMeta({ status: 'idle', metadata: undefined, error: undefined });
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
let cancelled = false;
|
|
92
|
+
setMeta({ status: 'loading', metadata: undefined, error: undefined });
|
|
93
|
+
getMetadata(refFn(storage, normalized))
|
|
94
|
+
.then((metadata) => {
|
|
95
|
+
if (cancelled) return;
|
|
96
|
+
setMeta({ status: 'success', metadata, error: undefined });
|
|
97
|
+
})
|
|
98
|
+
.catch((e) => {
|
|
99
|
+
if (cancelled) return;
|
|
100
|
+
setMeta({
|
|
101
|
+
status: 'error',
|
|
102
|
+
metadata: undefined,
|
|
103
|
+
error: e instanceof Error ? e : new Error(String(e)),
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
return () => {
|
|
107
|
+
cancelled = true;
|
|
108
|
+
};
|
|
109
|
+
// `active` is derived from storage/normalized, not a dep itself.
|
|
110
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
111
|
+
}, [storage, normalized, tick]);
|
|
112
|
+
|
|
113
|
+
// Blob-URL lifecycle: one effect owns revocation. Runs cleanup when
|
|
114
|
+
// the url is replaced, reset (path change), or the hook unmounts :
|
|
115
|
+
// the design plan's "revoke on unmount" requirement.
|
|
116
|
+
const url = blobState.url;
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
if (url === undefined) return;
|
|
119
|
+
return () => {
|
|
120
|
+
URL.revokeObjectURL(url);
|
|
121
|
+
};
|
|
122
|
+
}, [url]);
|
|
123
|
+
|
|
124
|
+
const refresh = useCallback(() => setTick((n) => n + 1), []);
|
|
125
|
+
|
|
126
|
+
const loadBlob = useCallback(() => {
|
|
127
|
+
if (storage == null || normalized == null || normalized === '') return;
|
|
128
|
+
const myGen = ++blobGenRef.current;
|
|
129
|
+
setBlobState({ status: 'loading', blob: undefined, url: undefined, error: undefined });
|
|
130
|
+
getBlob(refFn(storage, normalized))
|
|
131
|
+
.then((blob) => {
|
|
132
|
+
if (myGen !== blobGenRef.current) return;
|
|
133
|
+
setBlobState({
|
|
134
|
+
status: 'success',
|
|
135
|
+
blob,
|
|
136
|
+
url: URL.createObjectURL(blob),
|
|
137
|
+
error: undefined,
|
|
138
|
+
});
|
|
139
|
+
})
|
|
140
|
+
.catch((e) => {
|
|
141
|
+
if (myGen !== blobGenRef.current) return;
|
|
142
|
+
setBlobState({
|
|
143
|
+
status: 'error',
|
|
144
|
+
blob: undefined,
|
|
145
|
+
url: undefined,
|
|
146
|
+
error: e instanceof Error ? e : new Error(String(e)),
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
}, [storage, normalized]);
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
status: meta.status,
|
|
153
|
+
metadata: meta.metadata,
|
|
154
|
+
error: meta.error,
|
|
155
|
+
refresh,
|
|
156
|
+
blobStatus: blobState.status,
|
|
157
|
+
blob: blobState.blob,
|
|
158
|
+
blobUrl: blobState.url,
|
|
159
|
+
blobError: blobState.error,
|
|
160
|
+
loadBlob,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
TARGET_SYMBOL,
|
|
4
|
+
parseStorageRules,
|
|
5
|
+
evaluateStorageRules,
|
|
6
|
+
type FirebaseStorage,
|
|
7
|
+
type StorageAuth,
|
|
8
|
+
type StorageMethod,
|
|
9
|
+
type StorageRules,
|
|
10
|
+
} from 'pyric/storage';
|
|
11
|
+
import { normalizeStoragePath } from './usePathState.js';
|
|
12
|
+
|
|
13
|
+
export type StorageRulesGateStatus = 'idle' | 'loading' | 'ready' | 'error';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Where the active ruleset came from:
|
|
17
|
+
* - `'option'` — the explicit `rules` option (string or pre-parsed).
|
|
18
|
+
* - `'sandbox'` — the ruleset deployed on the sandbox handle
|
|
19
|
+
* (`getStorageSandbox(ctx, { rules })`), read off the handle's
|
|
20
|
+
* `StorageService`.
|
|
21
|
+
* - `'none'` — no rules reachable. Every verdict allows
|
|
22
|
+
* (open-by-default, the same semantics `pyric/storage`'s
|
|
23
|
+
* enforcement layer applies when no rules are configured).
|
|
24
|
+
*/
|
|
25
|
+
export type StorageRulesSource = 'option' | 'sandbox' | 'none';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Per-path verdict. `pyric/storage`'s rules subset has exactly two
|
|
29
|
+
* verbs (`read` | `write` — the granular get/list/create/update/
|
|
30
|
+
* delete forms are a parser follow-up), so `delete` and `upload` are
|
|
31
|
+
* DERIVED aliases of `write`: Firebase Storage's `write` permission
|
|
32
|
+
* governs create, overwrite, AND delete.
|
|
33
|
+
*/
|
|
34
|
+
export interface StorageGateVerdict {
|
|
35
|
+
read: boolean;
|
|
36
|
+
write: boolean;
|
|
37
|
+
/** Derived — always `=== write` under the two-verb subset. */
|
|
38
|
+
delete: boolean;
|
|
39
|
+
/** Derived — always `=== write` under the two-verb subset. */
|
|
40
|
+
upload: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Evaluator reason traces for DENIED verbs (`"no rule matches…"` /
|
|
43
|
+
* `"match /… : condition false"`); empty arrays when allowed.
|
|
44
|
+
* Feed into disabled-state tooltips and `data-*-reason` attributes.
|
|
45
|
+
*/
|
|
46
|
+
reasons: { read: string[]; write: string[] };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface UseStorageRulesGateOptions {
|
|
50
|
+
/**
|
|
51
|
+
* Path (or paths) to pre-evaluate into `verdicts`, keyed by the
|
|
52
|
+
* normalized path. Ad-hoc paths (e.g. browser rows) go through
|
|
53
|
+
* `verdictFor` instead — the two are the same evaluation.
|
|
54
|
+
*/
|
|
55
|
+
paths?: string | readonly string[];
|
|
56
|
+
/**
|
|
57
|
+
* Explicit rules source — raw rules text (parsed here; a malformed
|
|
58
|
+
* string surfaces as `status: 'error'`) or a pre-parsed
|
|
59
|
+
* `StorageRules` handle. Overrides the sandbox's deployed ruleset
|
|
60
|
+
* when both exist.
|
|
61
|
+
*/
|
|
62
|
+
rules?: string | StorageRules;
|
|
63
|
+
/**
|
|
64
|
+
* Identity override. `null` is anonymous; OMIT the field to use
|
|
65
|
+
* the handle's own identity (the sandbox context's `auth`).
|
|
66
|
+
*/
|
|
67
|
+
identity?: StorageAuth | null;
|
|
68
|
+
/**
|
|
69
|
+
* The about-to-write payload bound to `request.resource` for the
|
|
70
|
+
* WRITE evaluation — pass `{ size, contentType }` when gating a
|
|
71
|
+
* specific upload so size/contentType-conditioned rules evaluate
|
|
72
|
+
* truthfully. When omitted, `request.resource` is unset, which is
|
|
73
|
+
* exactly DELETE semantics (deletes carry no inbound payload) —
|
|
74
|
+
* a rule like `request.resource.size < N` then denies, the
|
|
75
|
+
* conservative answer for uploads.
|
|
76
|
+
*/
|
|
77
|
+
writeResource?: { size: number; contentType?: string };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface UseStorageRulesGateResult {
|
|
81
|
+
/** `'idle'` only when `storage` is null/undefined. */
|
|
82
|
+
status: StorageRulesGateStatus;
|
|
83
|
+
/** Where the active ruleset came from. */
|
|
84
|
+
source: StorageRulesSource;
|
|
85
|
+
/** Always false: `pyric/storage` handles are sandbox mirrors. */
|
|
86
|
+
advisory: boolean;
|
|
87
|
+
/** The identity the verdicts evaluate under. */
|
|
88
|
+
identity: StorageAuth | null;
|
|
89
|
+
/** Pre-evaluated verdicts for `options.paths`, keyed by normalized path. */
|
|
90
|
+
verdicts: Record<string, StorageGateVerdict>;
|
|
91
|
+
/**
|
|
92
|
+
* Evaluate an arbitrary path under the current ruleset + identity.
|
|
93
|
+
* Pure and synchronous once `status` is `'ready'`; before that
|
|
94
|
+
* (and whenever no rules are reachable) it returns the allow-all
|
|
95
|
+
* verdict — the gate FAILS OPEN, because affordances are advisory
|
|
96
|
+
* and the real enforcement (sandbox throw / server denial) stays
|
|
97
|
+
* authoritative.
|
|
98
|
+
*/
|
|
99
|
+
verdictFor: (path: string) => StorageGateVerdict;
|
|
100
|
+
/** Rules-resolution failure (e.g. a malformed `rules` string). */
|
|
101
|
+
error: Error | undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Shared allow-all verdict — idle/loading/error and rules-less states. */
|
|
105
|
+
const ALLOW_ALL: StorageGateVerdict = Object.freeze({
|
|
106
|
+
read: true,
|
|
107
|
+
write: true,
|
|
108
|
+
delete: true,
|
|
109
|
+
upload: true,
|
|
110
|
+
reasons: Object.freeze({
|
|
111
|
+
read: [] as string[],
|
|
112
|
+
write: [] as string[],
|
|
113
|
+
}) as StorageGateVerdict['reasons'],
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
interface RulesState {
|
|
117
|
+
status: StorageRulesGateStatus;
|
|
118
|
+
rules: StorageRules | null;
|
|
119
|
+
source: StorageRulesSource;
|
|
120
|
+
error: Error | undefined;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Pre-flight rules evaluation — the M7 differentiator. Evaluates the
|
|
125
|
+
* current identity against paths BEFORE the click, so components can
|
|
126
|
+
* annotate denied affordances (`data-pyric-denied`, disabled-with-
|
|
127
|
+
* reason) instead of letting the user discover a denial via a thrown
|
|
128
|
+
* `storage/unauthorized`.
|
|
129
|
+
*
|
|
130
|
+
* Rules discovery: a sandbox handle carries its deployed ruleset
|
|
131
|
+
* (`getStorageSandbox(ctx, { rules })` parses it into the handle's
|
|
132
|
+
* `StorageService`) — the hook reads it through the handle's target,
|
|
133
|
+
* so sandbox callers pass nothing. Identity likewise defaults to the
|
|
134
|
+
* handle's `SandboxContext.auth`. An explicit `rules` or `identity`
|
|
135
|
+
* option overrides the handle when evaluating a what-if scenario.
|
|
136
|
+
*
|
|
137
|
+
* Evaluation contract (mirrors `pyric/storage`'s own enforcement):
|
|
138
|
+
* `resource` (the existing object) is bound as `null` — the gate
|
|
139
|
+
* pre-evaluates without fetching per-path metadata, matching how the
|
|
140
|
+
* sandbox enforces `listAll`. Rules conditioned on existing-object
|
|
141
|
+
* state (`resource.*`) evaluate as if the object doesn't exist; the
|
|
142
|
+
* common identity/path/payload-shaped rules evaluate exactly.
|
|
143
|
+
*/
|
|
144
|
+
export function useStorageRulesGate(
|
|
145
|
+
storage: FirebaseStorage | null | undefined,
|
|
146
|
+
options: UseStorageRulesGateOptions = {},
|
|
147
|
+
): UseStorageRulesGateResult {
|
|
148
|
+
const { paths, rules: rulesOption, identity: identityOption, writeResource } = options;
|
|
149
|
+
|
|
150
|
+
const target = storage == null ? null : storage[TARGET_SYMBOL];
|
|
151
|
+
|
|
152
|
+
const [state, setState] = useState<RulesState>(() => ({
|
|
153
|
+
status: storage == null ? 'idle' : 'loading',
|
|
154
|
+
rules: null,
|
|
155
|
+
source: 'none',
|
|
156
|
+
error: undefined,
|
|
157
|
+
}));
|
|
158
|
+
|
|
159
|
+
useEffect(() => {
|
|
160
|
+
if (target == null) {
|
|
161
|
+
setState({ status: 'idle', rules: null, source: 'none', error: undefined });
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
// Explicit option wins for what-if evaluation.
|
|
165
|
+
if (rulesOption != null) {
|
|
166
|
+
try {
|
|
167
|
+
const parsed =
|
|
168
|
+
typeof rulesOption === 'string' ? parseStorageRules(rulesOption) : rulesOption;
|
|
169
|
+
setState({ status: 'ready', rules: parsed, source: 'option', error: undefined });
|
|
170
|
+
} catch (e) {
|
|
171
|
+
setState({
|
|
172
|
+
status: 'error',
|
|
173
|
+
rules: null,
|
|
174
|
+
source: 'none',
|
|
175
|
+
error: e instanceof Error ? e : new Error(String(e)),
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
let cancelled = false;
|
|
181
|
+
setState({ status: 'loading', rules: null, source: 'none', error: undefined });
|
|
182
|
+
target.servicePromise
|
|
183
|
+
.then((service) => {
|
|
184
|
+
if (cancelled) return;
|
|
185
|
+
setState({
|
|
186
|
+
status: 'ready',
|
|
187
|
+
rules: service.rules,
|
|
188
|
+
source: service.rules ? 'sandbox' : 'none',
|
|
189
|
+
error: undefined,
|
|
190
|
+
});
|
|
191
|
+
})
|
|
192
|
+
.catch((e) => {
|
|
193
|
+
if (cancelled) return;
|
|
194
|
+
setState({
|
|
195
|
+
status: 'error',
|
|
196
|
+
rules: null,
|
|
197
|
+
source: 'none',
|
|
198
|
+
error: e instanceof Error ? e : new Error(String(e)),
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
return () => {
|
|
202
|
+
cancelled = true;
|
|
203
|
+
};
|
|
204
|
+
}, [target, rulesOption]);
|
|
205
|
+
|
|
206
|
+
// `undefined` means "derive from the handle"; an explicit `null`
|
|
207
|
+
// means anonymous.
|
|
208
|
+
const identity: StorageAuth | null =
|
|
209
|
+
identityOption !== undefined ? identityOption : (target?.context.auth ?? null);
|
|
210
|
+
|
|
211
|
+
const { rules, status } = state;
|
|
212
|
+
const writeSize = writeResource?.size;
|
|
213
|
+
const writeContentType = writeResource?.contentType;
|
|
214
|
+
|
|
215
|
+
const verdictFor = useCallback(
|
|
216
|
+
(path: string): StorageGateVerdict => {
|
|
217
|
+
if (rules == null) return ALLOW_ALL;
|
|
218
|
+
const objectPath = normalizeStoragePath(path);
|
|
219
|
+
const bucket = target?.bucket ?? 'pyric-default';
|
|
220
|
+
const rulesPath = objectPath === '' ? `b/${bucket}/o` : `b/${bucket}/o/${objectPath}`;
|
|
221
|
+
const evaluate = (method: StorageMethod) =>
|
|
222
|
+
evaluateStorageRules(rules, {
|
|
223
|
+
request: {
|
|
224
|
+
auth: identity,
|
|
225
|
+
method,
|
|
226
|
+
path: rulesPath,
|
|
227
|
+
...(method === 'write' && writeSize !== undefined
|
|
228
|
+
? { resource: { size: writeSize, contentType: writeContentType } }
|
|
229
|
+
: {}),
|
|
230
|
+
},
|
|
231
|
+
resource: null,
|
|
232
|
+
});
|
|
233
|
+
const read = evaluate('read');
|
|
234
|
+
const write = evaluate('write');
|
|
235
|
+
return {
|
|
236
|
+
read: read.allowed,
|
|
237
|
+
write: write.allowed,
|
|
238
|
+
delete: write.allowed,
|
|
239
|
+
upload: write.allowed,
|
|
240
|
+
reasons: {
|
|
241
|
+
read: read.allowed ? [] : read.reasons,
|
|
242
|
+
write: write.allowed ? [] : write.reasons,
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
},
|
|
246
|
+
[rules, target?.bucket, identity, writeSize, writeContentType],
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
const pathsKey = typeof paths === 'string' ? paths : (paths ?? []).join('\n');
|
|
250
|
+
const verdicts = useMemo(() => {
|
|
251
|
+
const list = typeof paths === 'string' ? [paths] : (paths ?? []);
|
|
252
|
+
const out: Record<string, StorageGateVerdict> = {};
|
|
253
|
+
for (const p of list) {
|
|
254
|
+
out[normalizeStoragePath(p)] = verdictFor(p);
|
|
255
|
+
}
|
|
256
|
+
return out;
|
|
257
|
+
// `pathsKey` stands in for the (possibly inline) array identity,
|
|
258
|
+
// so a fresh-but-equal `paths` array doesn't churn the record.
|
|
259
|
+
}, [pathsKey, verdictFor]);
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
status,
|
|
263
|
+
source: state.source,
|
|
264
|
+
advisory: false,
|
|
265
|
+
identity,
|
|
266
|
+
verdicts,
|
|
267
|
+
verdictFor,
|
|
268
|
+
error: state.error,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { useCallback, useMemo, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What the selection tracks per row — a structural subset of
|
|
5
|
+
* `StorageListEntry`, so `useStorageList`'s entries pass straight
|
|
6
|
+
* in. The `kind` decides the delete verb later (object →
|
|
7
|
+
* `deleteObject`, folder → recursive).
|
|
8
|
+
*/
|
|
9
|
+
export interface StorageSelectionEntry {
|
|
10
|
+
kind: 'folder' | 'object';
|
|
11
|
+
fullPath: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface UseStorageSelectionResult {
|
|
15
|
+
/** Selected entries in selection order. */
|
|
16
|
+
selected: StorageSelectionEntry[];
|
|
17
|
+
size: number;
|
|
18
|
+
isSelected: (fullPath: string) => boolean;
|
|
19
|
+
/** Add/remove — the checkbox verb. */
|
|
20
|
+
toggle: (entry: StorageSelectionEntry) => void;
|
|
21
|
+
select: (entry: StorageSelectionEntry) => void;
|
|
22
|
+
deselect: (fullPath: string) => void;
|
|
23
|
+
/** Replace the selection (e.g. a "select all" over
|
|
24
|
+
* `list.entries`). */
|
|
25
|
+
selectAll: (entries: StorageSelectionEntry[]) => void;
|
|
26
|
+
clear: () => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Multi-select state over storage rows, keyed by `fullPath`.
|
|
31
|
+
* Deliberately dumb: it doesn't watch the list, so clear it on path
|
|
32
|
+
* change (or after a bulk op via the delete hook's outcome) — the
|
|
33
|
+
* consumer owns that policy.
|
|
34
|
+
*/
|
|
35
|
+
export function useStorageSelection(): UseStorageSelectionResult {
|
|
36
|
+
const [map, setMap] = useState<Map<string, StorageSelectionEntry>>(
|
|
37
|
+
() => new Map(),
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const isSelected = useCallback(
|
|
41
|
+
(fullPath: string) => map.has(fullPath),
|
|
42
|
+
[map],
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const select = useCallback((entry: StorageSelectionEntry) => {
|
|
46
|
+
setMap((prev) => {
|
|
47
|
+
if (prev.has(entry.fullPath)) return prev;
|
|
48
|
+
const next = new Map(prev);
|
|
49
|
+
next.set(entry.fullPath, entry);
|
|
50
|
+
return next;
|
|
51
|
+
});
|
|
52
|
+
}, []);
|
|
53
|
+
|
|
54
|
+
const deselect = useCallback((fullPath: string) => {
|
|
55
|
+
setMap((prev) => {
|
|
56
|
+
if (!prev.has(fullPath)) return prev;
|
|
57
|
+
const next = new Map(prev);
|
|
58
|
+
next.delete(fullPath);
|
|
59
|
+
return next;
|
|
60
|
+
});
|
|
61
|
+
}, []);
|
|
62
|
+
|
|
63
|
+
const toggle = useCallback((entry: StorageSelectionEntry) => {
|
|
64
|
+
setMap((prev) => {
|
|
65
|
+
const next = new Map(prev);
|
|
66
|
+
if (next.has(entry.fullPath)) next.delete(entry.fullPath);
|
|
67
|
+
else next.set(entry.fullPath, entry);
|
|
68
|
+
return next;
|
|
69
|
+
});
|
|
70
|
+
}, []);
|
|
71
|
+
|
|
72
|
+
const selectAll = useCallback((entries: StorageSelectionEntry[]) => {
|
|
73
|
+
setMap(new Map(entries.map((e) => [e.fullPath, e])));
|
|
74
|
+
}, []);
|
|
75
|
+
|
|
76
|
+
const clear = useCallback(() => setMap(new Map()), []);
|
|
77
|
+
|
|
78
|
+
const selected = useMemo(() => [...map.values()], [map]);
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
selected,
|
|
82
|
+
size: map.size,
|
|
83
|
+
isSelected,
|
|
84
|
+
toggle,
|
|
85
|
+
select,
|
|
86
|
+
deselect,
|
|
87
|
+
selectAll,
|
|
88
|
+
clear,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export * from './hooks/index.js';
|
|
2
|
+
|
|
3
|
+
// Injectable Storage API bundle (Pyric Studio data-backend swap): defaults to
|
|
4
|
+
// in-process `pyric/storage`; a consumer can provide the SharedWorker client.
|
|
5
|
+
export { StorageApiProvider, useStorageApi, type StorageApi } from './storageApi.js';
|
|
6
|
+
|
|
7
|
+
// M2: navigation shell
|
|
8
|
+
export {
|
|
9
|
+
PathBreadcrumb,
|
|
10
|
+
type PathBreadcrumbProps,
|
|
11
|
+
} from './components/PathBreadcrumb.js';
|
|
12
|
+
export {
|
|
13
|
+
ObjectBrowser,
|
|
14
|
+
type ObjectBrowserProps,
|
|
15
|
+
} from './components/ObjectBrowser.js';
|
|
16
|
+
|
|
17
|
+
// M3: upload
|
|
18
|
+
export {
|
|
19
|
+
UploadDropzone,
|
|
20
|
+
type UploadDropzoneProps,
|
|
21
|
+
type DroppedFile,
|
|
22
|
+
} from './components/UploadDropzone.js';
|
|
23
|
+
export {
|
|
24
|
+
splitStorageName,
|
|
25
|
+
parseCopyCounter,
|
|
26
|
+
resolveCollision,
|
|
27
|
+
planBatchNames,
|
|
28
|
+
} from './collisionRename.js';
|
|
29
|
+
export {
|
|
30
|
+
expandPathChain,
|
|
31
|
+
initialPendingPrefixes,
|
|
32
|
+
pendingPrefixReducer,
|
|
33
|
+
pendingChildFolders,
|
|
34
|
+
isPendingPrefix,
|
|
35
|
+
folderInputError,
|
|
36
|
+
type PendingPrefixState,
|
|
37
|
+
type PendingPrefixAction,
|
|
38
|
+
} from './pendingPrefixes.js';
|
|
39
|
+
|
|
40
|
+
// M4: inspection, content-type preview registry + inspector
|
|
41
|
+
export {
|
|
42
|
+
defaultStoragePreviews,
|
|
43
|
+
imagePreview,
|
|
44
|
+
textPreview,
|
|
45
|
+
selectStoragePreview,
|
|
46
|
+
TEXT_PREVIEW_MAX_BYTES,
|
|
47
|
+
type StoragePreview,
|
|
48
|
+
type StoragePreviewContext,
|
|
49
|
+
} from './previews.js';
|
|
50
|
+
export {
|
|
51
|
+
ObjectInspector,
|
|
52
|
+
type ObjectInspectorProps,
|
|
53
|
+
} from './components/ObjectInspector.js';
|
|
54
|
+
|
|
55
|
+
// M6: bulk ops
|
|
56
|
+
export {
|
|
57
|
+
DeleteSelectionWithConfirm,
|
|
58
|
+
type DeleteSelectionWithConfirmProps,
|
|
59
|
+
} from './components/DeleteSelectionWithConfirm.js';
|