@pippit-dev/cli 1.0.38 → 1.0.40

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 (45) hide show
  1. package/README.md +84 -1
  2. package/checksums.txt +6 -6
  3. package/cmd/auth/auth.go +4 -3
  4. package/cmd/marketing.go +5 -4
  5. package/cmd/marketing_test.go +58 -0
  6. package/cmd/root.go +40 -14
  7. package/cmd/root_test.go +63 -0
  8. package/cmd/source_test.go +2 -3
  9. package/cmd/submit_run_test.go +20 -81
  10. package/cmd/update/update.go +36 -10
  11. package/cmd/update/update_test.go +73 -3
  12. package/dist/checksums.txt +6 -6
  13. package/dist/xyq-canvas-command-runtime.cjs +14 -14
  14. package/dist/xyq-canvas-command-runtime.cjs.LEGAL.txt +0 -206
  15. package/dist/xyq-canvas-command-runtime.cjs.sha256 +2 -2
  16. package/internal/adapter/adapter.go +73 -0
  17. package/internal/adapter/ppe.go +36 -0
  18. package/internal/adaptercli/commands.go +27 -0
  19. package/internal/adaptercli/commands_test.go +41 -0
  20. package/internal/auth/manager.go +25 -13
  21. package/internal/canvas/apply.go +3 -0
  22. package/internal/canvas/media_identity.go +300 -0
  23. package/internal/canvas/media_identity_test.go +160 -0
  24. package/internal/canvas/upload.go +30 -1
  25. package/internal/commandnames/names.go +9 -0
  26. package/internal/common/adapter_test.go +74 -0
  27. package/internal/common/client.go +37 -12
  28. package/internal/config/config.go +41 -0
  29. package/internal/config/config_test.go +41 -1
  30. package/internal/generate_video/generate_video.go +1 -0
  31. package/internal/generate_video/generate_video_test.go +25 -3
  32. package/internal/models/models.go +6 -2
  33. package/internal/models/models_test.go +7 -0
  34. package/package.json +2 -2
  35. package/scripts/canvas-command.js +4 -2
  36. package/skills/short-drama/SKILL.md +6 -3
  37. package/skills/short-drama/commands/download-result.md +9 -3
  38. package/skills/short-drama/commands/get-thread.md +2 -0
  39. package/skills/short-drama/commands/list-thread-file.md +12 -6
  40. package/skills/short-drama/commands/submit-run.md +3 -1
  41. package/skills/short-drama/examples/create-and-deliver.md +2 -2
  42. package/skills/short-drama/examples/reference-and-continue.md +20 -1
  43. package/skills/short-drama/workflows/creation.md +28 -14
  44. package/skills/short-drama/workflows/poll-and-deliver.md +27 -9
  45. package/skills/xyq-nest-skill/commands/canvas-assets.md +9 -1
@@ -0,0 +1,300 @@
1
+ package canvas
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "fmt"
7
+ "strings"
8
+
9
+ "github.com/Pippit-dev/pippit-cli/internal/common"
10
+ )
11
+
12
+ var canvasMediaAssetTypes = map[string]int{"image": 1, "video": 2, "audio": 3}
13
+
14
+ func mediaIdentityError(nodeID, field, reason string) error {
15
+ return fmt.Errorf("INVALID_MEDIA_IDENTITY asset %q at %s: %s", nodeID, field, reason)
16
+ }
17
+
18
+ func identityField(values map[string]json.RawMessage, name string) (json.RawMessage, error) {
19
+ var result json.RawMessage
20
+ for key, value := range values {
21
+ if !strings.EqualFold(key, name) {
22
+ continue
23
+ }
24
+ if result != nil {
25
+ return nil, fmt.Errorf("duplicate %s fields are ambiguous", name)
26
+ }
27
+ result = value
28
+ }
29
+ return result, nil
30
+ }
31
+
32
+ func identityString(values map[string]json.RawMessage, name string) (string, error) {
33
+ raw, err := identityField(values, name)
34
+ if err != nil {
35
+ return "", err
36
+ }
37
+ if raw == nil || string(raw) == "null" {
38
+ return "", nil
39
+ }
40
+ var value string
41
+ if err := json.Unmarshal(raw, &value); err != nil {
42
+ return "", fmt.Errorf("%s must be a JSON string", name)
43
+ }
44
+ return strings.TrimSpace(value), nil
45
+ }
46
+
47
+ func mediaSection(raw json.RawMessage, kind string) (map[string]json.RawMessage, error) {
48
+ var asset map[string]json.RawMessage
49
+ if err := json.Unmarshal(raw, &asset); err != nil {
50
+ return nil, err
51
+ }
52
+ // Native AgentAsset type, not preview sections: videos may also contain Image.
53
+ expectedType := canvasMediaAssetTypes[kind]
54
+ if typeRaw, ok := rawField(asset, "AssetType", "asset_type"); ok {
55
+ var actual int
56
+ if err := json.Unmarshal(typeRaw, &actual); err != nil || actual != expectedType {
57
+ return nil, fmt.Errorf("expected %s material", kind)
58
+ }
59
+ }
60
+ sectionRaw, ok := rawField(asset, strings.ToUpper(kind[:1])+kind[1:], kind)
61
+ if !ok {
62
+ return nil, fmt.Errorf("expected %s material", kind)
63
+ }
64
+ var section map[string]json.RawMessage
65
+ if err := json.Unmarshal(sectionRaw, &section); err != nil || section == nil {
66
+ return nil, fmt.Errorf("invalid %s material section", kind)
67
+ }
68
+ return section, nil
69
+ }
70
+
71
+ func cloudMediaID(raw json.RawMessage, kind string) (string, error) {
72
+ section, err := mediaSection(raw, kind)
73
+ if err != nil {
74
+ return "", err
75
+ }
76
+ id, err := optionalStringField(section, "asset_id", "assetId", "AssetID")
77
+ if err != nil {
78
+ return "", err
79
+ }
80
+ if id == "" {
81
+ return "", fmt.Errorf("media ID is not ready; query the material again")
82
+ }
83
+ pippitID, err := assetIDFromRaw(raw)
84
+ if err != nil {
85
+ return "", err
86
+ }
87
+ if id == pippitID {
88
+ return "", fmt.Errorf("cloud media ID cannot be the Pippit material ID")
89
+ }
90
+ return id, nil
91
+ }
92
+
93
+ func touchesMediaIdentity(path string) bool {
94
+ switch strings.ToLower(path) {
95
+ case "", "/type", "/content", "/content/assetid", "/content/pippitassetid":
96
+ return true
97
+ }
98
+ return false
99
+ }
100
+
101
+ func decodeNodeAsset(raw json.RawMessage) (map[string]json.RawMessage, error) {
102
+ var record map[string]json.RawMessage
103
+ if err := json.Unmarshal(raw, &record); err != nil {
104
+ return nil, err
105
+ }
106
+ textRaw, ok := rawField(record, "TextInfo", "Text", "textInfo", "text")
107
+ if !ok {
108
+ return nil, fmt.Errorf("node asset has no inline text content")
109
+ }
110
+ var text map[string]json.RawMessage
111
+ if err := json.Unmarshal(textRaw, &text); err != nil {
112
+ return nil, err
113
+ }
114
+ content, ok := rawField(text, "Content", "content")
115
+ if !ok {
116
+ return nil, fmt.Errorf("node asset has no inline text content")
117
+ }
118
+ var value string
119
+ if json.Unmarshal(content, &value) == nil {
120
+ content = json.RawMessage(value)
121
+ }
122
+ var node map[string]json.RawMessage
123
+ if err := json.Unmarshal(content, &node); err != nil || node == nil {
124
+ return nil, fmt.Errorf("invalid node content")
125
+ }
126
+ return node, nil
127
+ }
128
+
129
+ // Only identity paths and their parents are projected. Other fields stay in the
130
+ // original request, so validation never reconstructs or rewrites persisted data.
131
+ func projectIdentityPatch(node map[string]json.RawMessage, patch PatchEntry) (map[string]json.RawMessage, error) {
132
+ if patch.Path == "" {
133
+ if patch.Op == "remove" {
134
+ return nil, nil
135
+ }
136
+ var value map[string]json.RawMessage
137
+ if err := json.Unmarshal(patch.Value, &value); err != nil {
138
+ return nil, err
139
+ }
140
+ return value, nil
141
+ }
142
+ if node == nil {
143
+ return nil, fmt.Errorf("cannot validate a patch without its node base")
144
+ }
145
+ parts := strings.Split(strings.TrimPrefix(patch.Path, "/"), "/")
146
+ key := parts[0]
147
+ if len(parts) == 1 {
148
+ if patch.Op == "remove" {
149
+ delete(node, key)
150
+ } else {
151
+ node[key] = patch.Value
152
+ }
153
+ return node, nil
154
+ }
155
+ var content map[string]json.RawMessage
156
+ if err := json.Unmarshal(node[key], &content); err != nil || content == nil {
157
+ return nil, fmt.Errorf("cannot validate a media identity without its content base")
158
+ }
159
+ if patch.Op == "remove" {
160
+ delete(content, parts[1])
161
+ } else {
162
+ content[parts[1]] = patch.Value
163
+ }
164
+ value, err := json.Marshal(content)
165
+ if err != nil {
166
+ return nil, err
167
+ }
168
+ node[key] = value
169
+ return node, nil
170
+ }
171
+
172
+ func readNodeIdentity(node map[string]json.RawMessage) (kind, pippitID, mediaID string, err error) {
173
+ if node == nil {
174
+ return
175
+ }
176
+ kind, err = identityString(node, "type")
177
+ if err != nil {
178
+ return
179
+ }
180
+ switch kind {
181
+ case "biz/image", "biz/video", "biz/audio":
182
+ kind = strings.TrimPrefix(kind, "biz/")
183
+ default:
184
+ kind = ""
185
+ return
186
+ }
187
+ contentRaw, fieldErr := identityField(node, "content")
188
+ if fieldErr != nil {
189
+ err = fieldErr
190
+ return
191
+ }
192
+ var content map[string]json.RawMessage
193
+ if err = json.Unmarshal(contentRaw, &content); err != nil {
194
+ return
195
+ }
196
+ pippitID, err = identityString(content, "pippitAssetId")
197
+ if err != nil {
198
+ return
199
+ }
200
+ mediaID, err = identityString(content, "assetId")
201
+ return
202
+ }
203
+
204
+ func validateApplyMediaIdentities(ctx context.Context, request ApplyRequest, runner *common.Runner) error {
205
+ patches := request.Transactions[0].Patches
206
+ affected := make(map[string]bool)
207
+ var ordered []string
208
+ var toQuery []string
209
+ for _, patch := range patches {
210
+ if touchesMediaIdentity(patch.Path) && !affected[patch.AssetID] {
211
+ affected[patch.AssetID] = true
212
+ ordered = append(ordered, patch.AssetID)
213
+ if patch.Path != "" {
214
+ toQuery = append(toQuery, patch.AssetID)
215
+ }
216
+ }
217
+ }
218
+ nodes := make(map[string]map[string]json.RawMessage)
219
+ type identity struct{ nodeID, kind, pippitID, mediaID string }
220
+ previous := make(map[string]identity)
221
+ if len(toQuery) > 0 {
222
+ result, err := queryAssets(ctx, toQuery, true, runner)
223
+ if err != nil {
224
+ return fmt.Errorf("canvas media identity preflight failed: %w", err)
225
+ }
226
+ for _, raw := range result.Assets {
227
+ id, _ := assetIDFromRaw(raw)
228
+ node, err := decodeNodeAsset(raw)
229
+ if err != nil {
230
+ return mediaIdentityError(id, "/content", err.Error())
231
+ }
232
+ nodes[id] = node
233
+ kind, pippitID, mediaID, readErr := readNodeIdentity(node)
234
+ if readErr == nil {
235
+ previous[id] = identity{id, kind, pippitID, mediaID}
236
+ }
237
+ }
238
+ }
239
+ for _, patch := range patches {
240
+ if !affected[patch.AssetID] || !touchesMediaIdentity(patch.Path) {
241
+ continue
242
+ }
243
+ node, err := projectIdentityPatch(nodes[patch.AssetID], patch)
244
+ if err != nil {
245
+ return mediaIdentityError(patch.AssetID, patch.Path, err.Error())
246
+ }
247
+ nodes[patch.AssetID] = node
248
+ }
249
+ var identities []identity
250
+ var materialIDs []string
251
+ seen := make(map[string]bool)
252
+ for _, nodeID := range ordered {
253
+ kind, pippitID, mediaID, err := readNodeIdentity(nodes[nodeID])
254
+ if err != nil {
255
+ return mediaIdentityError(nodeID, "/content", err.Error())
256
+ }
257
+ if kind == "" || (pippitID == "" && mediaID == "") {
258
+ continue
259
+ }
260
+ if old, ok := previous[nodeID]; ok && old.kind == kind && old.pippitID == pippitID && old.mediaID == mediaID {
261
+ continue
262
+ }
263
+ if mediaID == nodeID || (mediaID != "" && mediaID == pippitID) {
264
+ return mediaIdentityError(nodeID, "/content/assetId", "expected cloud media ID, received Pippit material or Canvas node ID")
265
+ }
266
+ if pippitID == "" {
267
+ return mediaIdentityError(nodeID, "/content/pippitAssetId", "Pippit material ID is required to verify the media")
268
+ }
269
+ if mediaID == "" {
270
+ return mediaIdentityError(nodeID, "/content/assetId", "use a semantic media command to resolve pippitAssetId")
271
+ }
272
+ identities = append(identities, identity{nodeID, kind, pippitID, mediaID})
273
+ if !seen[pippitID] {
274
+ materialIDs = append(materialIDs, pippitID)
275
+ seen[pippitID] = true
276
+ }
277
+ }
278
+ if len(materialIDs) == 0 {
279
+ return nil
280
+ }
281
+ materials, err := queryAssets(ctx, materialIDs, true, runner)
282
+ if err != nil {
283
+ return fmt.Errorf("canvas media identity preflight failed: %w", err)
284
+ }
285
+ byID := make(map[string]json.RawMessage)
286
+ for _, raw := range materials.Assets {
287
+ id, _ := assetIDFromRaw(raw)
288
+ byID[id] = raw
289
+ }
290
+ for _, item := range identities {
291
+ expected, err := cloudMediaID(byID[item.pippitID], item.kind)
292
+ if err != nil {
293
+ return mediaIdentityError(item.nodeID, "/content/assetId", err.Error())
294
+ }
295
+ if expected != item.mediaID {
296
+ return mediaIdentityError(item.nodeID, "/content/assetId", "media ID does not belong to the Pippit material")
297
+ }
298
+ }
299
+ return nil
300
+ }
@@ -0,0 +1,160 @@
1
+ package canvas
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "fmt"
7
+ "os"
8
+ "path/filepath"
9
+ "strings"
10
+ "testing"
11
+
12
+ "github.com/Pippit-dev/pippit-cli/internal/common"
13
+ )
14
+
15
+ const testPippitID = "10155947700492"
16
+ const testMediaID = "7693046454219440665"
17
+ const testNodeID = "10901596019212"
18
+
19
+ func imageNode(mediaID string) json.RawMessage {
20
+ return json.RawMessage(fmt.Sprintf(`{"type":"biz/image","pippitAssetId":"%s","content":{"pippitAssetId":"%s","assetId":"%s","caption":"keep"}}`, testNodeID, testPippitID, mediaID))
21
+ }
22
+
23
+ func applyIdentityFixture(patches []PatchEntry, mediaRaw string) (ApplyOptions, *common.Runner, *int) {
24
+ writes := 0
25
+ client := &fakeClient{send: func(_ context.Context, path string, body, out any) error {
26
+ switch path {
27
+ case QueryPath:
28
+ request := body.(queryRequest)
29
+ var assets []json.RawMessage
30
+ for _, id := range request.PippitAssetIDs {
31
+ if id == testNodeID {
32
+ content, _ := json.Marshal(string(imageNode(testMediaID)))
33
+ assets = append(assets, json.RawMessage(fmt.Sprintf(`{"PippitAssetID":"%s","TextInfo":{"content":%s},"version":5}`, id, content)))
34
+ } else if id == testPippitID {
35
+ assets = append(assets, json.RawMessage(mediaRaw))
36
+ }
37
+ }
38
+ payload, _ := json.Marshal(map[string]any{"ret": "0", "data": map[string]any{"Assets": assets}})
39
+ return decodeInto(out, string(payload))
40
+ case ApplyPath:
41
+ writes++
42
+ return decodeInto(out, fmt.Sprintf(`{"ret":"0","data":{"results":[{"transaction_id":"tx","status":"ack","asset_versions":{"%s":6}}]}}`, testNodeID))
43
+ default:
44
+ return fmt.Errorf("unexpected request %s", path)
45
+ }
46
+ }}
47
+ return ApplyOptions{Request: ApplyRequest{
48
+ BatchID: "batch", ClientID: "client", Transactions: []PatchTransaction{{TransactionID: "tx", Patches: patches}},
49
+ }}, runnerWithClient(client), &writes
50
+ }
51
+
52
+ func imageMaterial() string {
53
+ return fmt.Sprintf(`{"PippitAssetID":"%s","AssetType":1,"SourceID":"workspace-source-id","Image":{"asset_id":"%s"}}`, testPippitID, testMediaID)
54
+ }
55
+
56
+ func TestApplyMediaIdentityRejectsBadIDsBeforeWrite(t *testing.T) {
57
+ for _, wrong := range []string{testPippitID, testNodeID, "workspace-source-id", "7693046454219440666"} {
58
+ t.Run(wrong, func(t *testing.T) {
59
+ opts, runner, writes := applyIdentityFixture([]PatchEntry{{AssetID: testNodeID, Op: "add", Path: "", Value: imageNode(wrong)}}, imageMaterial())
60
+ _, err := Apply(context.Background(), opts, runner)
61
+ if err == nil || !strings.Contains(err.Error(), "INVALID_MEDIA_IDENTITY") {
62
+ t.Fatalf("error = %v", err)
63
+ }
64
+ if *writes != 0 {
65
+ t.Fatalf("invalid data was written %d times", *writes)
66
+ }
67
+ })
68
+ }
69
+ }
70
+
71
+ func TestApplyMediaIdentityChecksFinalTransactionAndAllowsValidPair(t *testing.T) {
72
+ patches := []PatchEntry{
73
+ {AssetID: testNodeID, Op: "add", Path: "", Value: imageNode(testPippitID)},
74
+ {AssetID: testNodeID, Op: "replace", Path: "/content/assetId", Value: json.RawMessage(`"` + testMediaID + `"`)},
75
+ }
76
+ opts, runner, writes := applyIdentityFixture(patches, imageMaterial())
77
+ if _, err := Apply(context.Background(), opts, runner); err != nil {
78
+ t.Fatal(err)
79
+ }
80
+ if *writes != 1 {
81
+ t.Fatalf("writes = %d", *writes)
82
+ }
83
+ }
84
+
85
+ func TestApplyMediaIdentityRejectsPartialPatchBypass(t *testing.T) {
86
+ opts, runner, writes := applyIdentityFixture([]PatchEntry{{AssetID: testNodeID, Op: "replace", Path: "/content/assetId", Value: json.RawMessage(`"` + testPippitID + `"`)}}, imageMaterial())
87
+ if _, err := Apply(context.Background(), opts, runner); err == nil {
88
+ t.Fatal("expected identity error")
89
+ }
90
+ if *writes != 0 {
91
+ t.Fatal("partial invalid patch was written")
92
+ }
93
+ }
94
+
95
+ func TestApplyMediaIdentityRejectsNumbersWrongTypeMissingOrUnreadyMaterial(t *testing.T) {
96
+ for name, raw := range map[string]string{
97
+ "numeric": fmt.Sprintf(`{"PippitAssetID":"%s","AssetType":1,"Image":{"asset_id":7693046454219440665}}`, testPippitID),
98
+ "wrong-type": fmt.Sprintf(`{"PippitAssetID":"%s","AssetType":2,"Image":{"asset_id":"%s"},"Video":{"asset_id":"video-id"}}`, testPippitID, testMediaID),
99
+ "unready": fmt.Sprintf(`{"PippitAssetID":"%s","AssetType":1,"Image":{}}`, testPippitID),
100
+ "missing": "null",
101
+ } {
102
+ t.Run(name, func(t *testing.T) {
103
+ opts, runner, writes := applyIdentityFixture([]PatchEntry{{AssetID: testNodeID, Op: "add", Path: "", Value: imageNode(testMediaID)}}, raw)
104
+ if _, err := Apply(context.Background(), opts, runner); err == nil {
105
+ t.Fatal("expected identity preflight error")
106
+ }
107
+ if *writes != 0 {
108
+ t.Fatal("invalid material was written")
109
+ }
110
+ })
111
+ }
112
+ }
113
+
114
+ func TestMediaUploadLocatorDistinguishesSourceAndTypedMedia(t *testing.T) {
115
+ for _, kind := range []string{"image", "video", "audio"} {
116
+ nativeType := map[string]int{"image": 1, "video": 2, "audio": 3}[kind]
117
+ raw := json.RawMessage(fmt.Sprintf(`{"PippitAssetID":"%s","AssetType":%d,"asset_id":"wrong-root-id","SourceID":"workspace-source-id","%s":{"asset_id":"%s"}}`, testPippitID, nativeType, strings.ToUpper(kind[:1])+kind[1:], testMediaID))
118
+ locator := locatorFromAsset(raw, testPippitID)
119
+ if locator.PippitAssetID != testPippitID || locator.MediaAssetID != testMediaID || locator.SourceID != "workspace-source-id" {
120
+ t.Fatalf("locator = %#v", locator)
121
+ }
122
+ }
123
+ }
124
+
125
+ func TestUploadExposesDistinctIdentifiersAndKeepsUnreadyMediaProcessing(t *testing.T) {
126
+ for _, ready := range []bool{true, false} {
127
+ t.Run(fmt.Sprint(ready), func(t *testing.T) {
128
+ file := filepath.Join(t.TempDir(), "reference.png")
129
+ if err := os.WriteFile(file, []byte("image"), 0600); err != nil {
130
+ t.Fatal(err)
131
+ }
132
+ raw := imageMaterial()
133
+ if !ready {
134
+ raw = fmt.Sprintf(`{"PippitAssetID":"%s","AssetType":1,"Image":{}}`, testPippitID)
135
+ }
136
+ client := &fakeClient{
137
+ multipart: func(_ context.Context, _ string, _ map[string]string, _ common.MultipartFile, out any) error {
138
+ return decodeInto(out, fmt.Sprintf(`{"ret":"0","data":{"asset_id":"workspace-source-id","pippit_asset_id":"%s"}}`, testPippitID))
139
+ },
140
+ send: func(_ context.Context, _ string, _ any, out any) error {
141
+ return decodeInto(out, `{"ret":"0","data":{"Assets":[`+raw+`]}}`)
142
+ },
143
+ }
144
+ result, err := Upload(context.Background(), UploadOptions{Path: file}, runnerWithClient(client))
145
+ if err != nil {
146
+ t.Fatal(err)
147
+ }
148
+ if result.PippitAssetID != testPippitID || result.SourceID != "workspace-source-id" || result.AssetID != result.SourceID {
149
+ t.Fatalf("result = %#v", result)
150
+ }
151
+ if ready {
152
+ if result.MediaAssetID != testMediaID || result.Locator.MediaAssetID != testMediaID || result.State != StateReady {
153
+ t.Fatalf("ready result = %#v", result)
154
+ }
155
+ } else if result.MediaAssetID != "" || result.State != StateProcessing || result.Warning == "" {
156
+ t.Fatalf("processing result = %#v", result)
157
+ }
158
+ })
159
+ }
160
+ }
@@ -38,6 +38,7 @@ type UploadOptions struct {
38
38
  // Canvas patches; callers should persist PippitAssetID instead.
39
39
  type AssetLocator struct {
40
40
  PippitAssetID string `json:"pippit_asset_id"`
41
+ MediaAssetID string `json:"media_asset_id,omitempty"`
41
42
  SourceID string `json:"source_id,omitempty"`
42
43
  DownloadURL string `json:"download_url,omitempty"`
43
44
  InternalURL string `json:"internal_url,omitempty"`
@@ -46,8 +47,11 @@ type AssetLocator struct {
46
47
  }
47
48
 
48
49
  type UploadResult struct {
49
- State string `json:"state"`
50
+ State string `json:"state"`
51
+ // Deprecated compatibility field: upload workspace/source ID, never cloud media ID.
50
52
  AssetID string `json:"asset_id,omitempty"`
53
+ SourceID string `json:"source_id,omitempty"`
54
+ MediaAssetID string `json:"media_asset_id,omitempty"`
51
55
  PippitAssetID string `json:"pippit_asset_id"`
52
56
  Locator AssetLocator `json:"locator"`
53
57
  Asset json.RawMessage `json:"asset,omitempty"`
@@ -105,6 +109,7 @@ func Upload(ctx context.Context, opts UploadOptions, runner *common.Runner) (*Up
105
109
  result := &UploadResult{
106
110
  State: StateProcessing,
107
111
  AssetID: assetID,
112
+ SourceID: assetID,
108
113
  PippitAssetID: pippitAssetID,
109
114
  Locator: AssetLocator{PippitAssetID: pippitAssetID},
110
115
  LogID: strings.TrimSpace(envelope.LogID),
@@ -121,8 +126,13 @@ func Upload(ctx context.Context, opts UploadOptions, runner *common.Runner) (*Up
121
126
  }
122
127
  result.State = StateReady
123
128
  result.Locator = locatorFromAsset(asset, pippitAssetID)
129
+ result.MediaAssetID = result.Locator.MediaAssetID
124
130
  result.Asset = asset
125
131
  result.QueryLogID = queryLogID
132
+ if uploadedMediaKind(asset) != "" && result.MediaAssetID == "" {
133
+ result.State = StateProcessing
134
+ result.Warning = "material is queryable but its cloud media ID is not ready; use a semantic media command or query again, do not substitute asset_id/source_id"
135
+ }
126
136
  return result, nil
127
137
  }
128
138
 
@@ -200,11 +210,30 @@ func uploadWaitError(err error, assetID string, timeout time.Duration) error {
200
210
  return fmt.Errorf("wait for uploaded canvas asset %q canceled: %w", assetID, err)
201
211
  }
202
212
 
213
+ func uploadedMediaKind(raw json.RawMessage) string {
214
+ var record map[string]json.RawMessage
215
+ _ = json.Unmarshal(raw, &record)
216
+ var nativeType int
217
+ _ = json.Unmarshal(record["AssetType"], &nativeType)
218
+ for kind, value := range canvasMediaAssetTypes {
219
+ if nativeType == value {
220
+ return kind
221
+ }
222
+ }
223
+ return ""
224
+ }
225
+
203
226
  func locatorFromAsset(raw json.RawMessage, pippitAssetID string) AssetLocator {
204
227
  var value any
205
228
  _ = json.Unmarshal(raw, &value)
229
+ kind := uploadedMediaKind(raw)
230
+ mediaID := ""
231
+ if kind != "" {
232
+ mediaID, _ = cloudMediaID(raw, kind)
233
+ }
206
234
  return AssetLocator{
207
235
  PippitAssetID: pippitAssetID,
236
+ MediaAssetID: mediaID,
208
237
  SourceID: findFirstString(value, "SourceID", "source_id", "sourceId"),
209
238
  DownloadURL: findFirstString(value, "DownloadUrl", "DownloadURL", "download_url", "downloadUrl"),
210
239
  InternalURL: findFirstString(value, "InternalUrl", "InternalURL", "internal_url", "internalUrl"),
@@ -0,0 +1,9 @@
1
+ // Package commandnames defines top-level command names shared by registration and capability filtering.
2
+ package commandnames
3
+
4
+ const (
5
+ Login = "login"
6
+ Logout = "logout"
7
+ Status = "status"
8
+ Update = "update"
9
+ )
@@ -0,0 +1,74 @@
1
+ package common
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "net/http"
7
+ "testing"
8
+ "time"
9
+
10
+ "github.com/Pippit-dev/pippit-cli/internal/adapter"
11
+ )
12
+
13
+ type decisionPolicy struct {
14
+ adapter.Default
15
+ decision adapter.AuthDecision
16
+ err error
17
+ prepared, stripped int
18
+ }
19
+
20
+ func (p *decisionPolicy) PrepareAPIRequest(context.Context, *http.Request) (adapter.AuthDecision, error) {
21
+ p.prepared++
22
+ return p.decision, p.err
23
+ }
24
+ func (p *decisionPolicy) StripSensitiveHeaders(req *http.Request) {
25
+ p.stripped++
26
+ req.Header.Del("X-Adapter-Auth")
27
+ }
28
+
29
+ // The common client handles authentication decisions and origin checks. Adapter errors must not trigger a fallback to local authentication.
30
+ func TestAdapterAuthenticationBoundary(t *testing.T) {
31
+ failed := errors.New("adapter rejected")
32
+ for _, tc := range []struct {
33
+ name, target string
34
+ decision adapter.AuthDecision
35
+ policyErr error
36
+ wantAuth, wantPrepare, wantStrip bool
37
+ wantError bool
38
+ }{
39
+ {name: "default", target: "https://api.example.test/path", wantAuth: true, wantPrepare: true},
40
+ {name: "provided", target: "https://api.example.test/path", decision: adapter.AuthProvided, wantPrepare: true},
41
+ {name: "error", target: "https://api.example.test/path", policyErr: failed, wantPrepare: true, wantError: true},
42
+ {name: "invalid", target: "https://api.example.test/path", decision: adapter.AuthDecision(99), wantPrepare: true, wantError: true},
43
+ {name: "external", target: "https://files.example.test/file", wantStrip: true},
44
+ {name: "different port", target: "https://api.example.test:8443/file", wantStrip: true},
45
+ {name: "different scheme", target: "http://api.example.test/file", wantStrip: true},
46
+ } {
47
+ t.Run(tc.name, func(t *testing.T) {
48
+ policy := &decisionPolicy{decision: tc.decision, err: tc.policyErr}
49
+ authCalls := 0
50
+ authorizer := NewAccessKeyContextProviderAuthorizer(func(context.Context) (string, error) { authCalls++; return "local-key", nil })
51
+ client := NewHTTPClientWithAdapter("https://api.example.test", time.Second, authorizer, policy).(*httpClient)
52
+ req, err := http.NewRequest(http.MethodGet, tc.target, nil)
53
+ if err != nil {
54
+ t.Fatal(err)
55
+ }
56
+ err = client.prepareRequest(context.Background(), req, map[string]string{"Authorization": "Bearer caller", "X-Adapter-Auth": "caller"})
57
+ if (err != nil) != tc.wantError {
58
+ t.Fatalf("error=%v", err)
59
+ }
60
+ if tc.policyErr != nil && !errors.Is(err, failed) {
61
+ t.Fatalf("adapter error lost: %v", err)
62
+ }
63
+ if (authCalls == 1) != tc.wantAuth || (policy.prepared == 1) != tc.wantPrepare || (policy.stripped == 1) != tc.wantStrip {
64
+ t.Fatalf("auth=%d prepare=%d strip=%d", authCalls, policy.prepared, policy.stripped)
65
+ }
66
+ if !tc.wantAuth && req.Header.Get("Authorization") != "" {
67
+ t.Fatal("caller Authorization leaked")
68
+ }
69
+ if tc.wantStrip && req.Header.Get("X-Adapter-Auth") != "" {
70
+ t.Fatal("external request retained adapter credentials")
71
+ }
72
+ })
73
+ }
74
+ }